# Plan: ANSI-formatted verbose output

## Goal

Replace the plain-text stderr diagnostics with ANSI-colored, structured output so operators can scan the agent loop at a glance. Stdout (final answer) stays untouched.

## Current state

All verbose output goes through two paths:

1. `log(msg, verbose)` in `agent.py:162` — generic one-liner, plain text, suppressed by `-q`.
2. `ThinkingState._log()` in `thinking.py:191` — guarded by `if self.verbose` (`thinking.py:129`), so also suppressed by `-q`.

Errors/warnings print directly to stderr with `"Error: ..."` / `"Warning: ..."` prefixes. These always print regardless of `-q`.

Everything is unformatted strings today.

## Design

### New module: `fmt.py`

A formatting module that owns all ANSI output. Uses the `rich` library for rendering — it handles TTY detection, `NO_COLOR`, Windows compatibility, and markup out of the box.

Create a `rich.Console(stderr=True)` instance as the module-level output target. Rich's Console already auto-detects TTY and respects `NO_COLOR`/`FORCE_COLOR` env vars natively. The `--color` / `--no-color` CLI flags map to `Console(force_terminal=True)` and `Console(no_color=True)` respectively.

Provide helper functions (not a class — keep it simple):

| Function | Purpose |
|---|---|
| `turn_header(n, max_n, token_est)` | `--- Turn 3/10 (~4200 tokens) ---` with cyan bold |
| `model_info(msg)` | Model discovery, reload, config lines in dim white |
| `llm_timing(elapsed, finish_reason)` | `LLM responded in 2.3s (finish_reason=stop)` green for stop, yellow for length |
| `tool_call(name, args_json)` | Tool name bold magenta, args in dim/gray, truncated |
| `tool_result(name, elapsed, preview)` | Green checkmark + timing, result preview in dim |
| `tool_error(name, msg)` | Red X + error text |
| `think_step(number, total, text, *, revision, branch)` | Yellow `[think 2/5]` prefix, italic thought text in gray |
| `assistant_text(text)` | Blue `[assistant]` prefix, text in white |
| `warning(msg)` | Yellow `Warning:` prefix |
| `error(msg)` | Red bold `Error:` prefix |
| `info(msg)` | Dim generic info line (loaded instructions, cleanup, context stats) |
| `context_stats(label, tokens)` | Dim line like `Context after compaction: ~3200 tokens` |
| `completion(turns, exit_code)` | Green/red summary line `Agent finished: 5 turns, exit=ok` |

All functions print directly to stderr. No return values needed.

### Rich markup safety

All dynamic/untrusted text (tool arguments, tool results, model output, think thoughts, file paths, error messages) must be escaped with `rich.markup.escape()` or wrapped in `rich.text.Text()` objects before being interpolated into Rich markup strings. This prevents model-generated text containing brackets (e.g. `[bold]`, `[link=...]`) from being misinterpreted as Rich markup or breaking formatting.

Rule of thumb for the implementation: static labels use markup (`[bold magenta]▶ read_file[/]`), dynamic values use `Text()` or `escape()`.

### Think step formatting details

`fmt.think_step()` must preserve all current semantics from `thinking.py:_log()`:

- Newline normalization: `re.sub(r"\s+", " ", text).strip()`
- Truncation to 200 characters
- Revision metadata: `rev:{revises_thought}` when `is_revision` is true
- Branch metadata: `branch:{branch_id} from:{branch_from_thought}` when branching

The formatting just adds color/style on top: yellow `[think N/M]` prefix, dim italic thought text. The `_log()` method stays as the owner of normalization/truncation logic and calls `fmt.think_step()` for the actual printing, rather than moving all that logic into `fmt.py`.

### Visual language

Uses Rich markup syntax internally. Example output (colors described in brackets):

```
── Turn 1/10 ─────────────────────────── ~2100 tokens ──       [cyan rule]
  Calling model qwen3-8b with max_tokens=4096                  [dim]
  LLM responded in 1.4s  finish_reason=tool_calls              [green]
  ▶ read_file                                                  [bold magenta]
    path: src/main.py                                          [dim]
  ✓ read_file  0.0s                                            [green]
    def main(): ...                                            [dim, truncated]
  ▶ grep                                                       [bold magenta]
    pattern: TODO  path: .                                     [dim]
  ✓ grep  0.1s                                                 [green]
    src/main.py:12: # TODO: refactor                           [dim, truncated]
  ~3400 tokens                                                 [dim right-aligned]

── Turn 2/10 ─────────────────────────── ~3400 tokens ──       [cyan rule]
  LLM responded in 2.1s  finish_reason=tool_calls              [green]
  ▶ think                                                      [yellow]
    [2/3] The user wants to refactor the main module...        [italic dim]
  ▶ write_file                                                 [bold magenta]
    path: src/main.py                                          [dim]
  ✓ write_file  0.0s                                           [green]
  ~4100 tokens                                                 [dim right-aligned]

── Turn 3/10 ─────────────────────────── ~4100 tokens ──       [cyan rule]
  LLM responded in 0.8s  finish_reason=stop                    [green]
  ✓ Agent finished: 3 turns                                    [bold green]
```

Error/warning examples:
```
  ⚠ context window exceeded, compacting history...             [yellow]
  ✗ LLM call failed: connection refused                        [bold red]
```

The turn header is a Rich `Rule` — a horizontal line with the turn info embedded, which adapts to terminal width automatically.

### Changes to existing files

**`pyproject.toml`**

- Add `"rich"` to `[project] dependencies`.
- Add `"fmt.py"` to the `only-include` list in `[tool.hatch.build.targets.wheel]` so it's included in the wheel. (Without this, `import fmt` will fail in installed packages.)

**`agent.py`**

- Import `fmt` module.
- Replace every `log(f"...", verbose)` call with the appropriate `fmt.*()` call, guarded by `if verbose:` (the guard stays in `agent.py`, not in `fmt.py`, so the formatting module stays stateless).
- Replace direct `print("Warning: ...", file=sys.stderr)` and `print("Error: ...", file=sys.stderr)` calls with `fmt.warning()` / `fmt.error()` — these always print regardless of verbose.
- Remove the `log()` function entirely.
- Add `--no-color` and `--color` as a mutually exclusive argparse group; pass result to `fmt.init()` at startup.

**`thinking.py`**

- Import `fmt` and replace the `print(...)` call inside `_log()` with `fmt.think_step(...)`.
- The `_log()` method keeps its normalization/truncation logic and the `if self.verbose` guard stays in `process()`. This preserves `-q` suppression of think logs (matching current behavior and tests).

**`skills.py`**

- Replace `print(f"Warning: ...", file=sys.stderr)` calls with `fmt.warning()`.
- Replace the discovery summary `log()` call with `fmt.info()`. These remain verbose-only (current behavior).

### CLI flags

`--color` and `--no-color` are defined as a **mutually exclusive argparse group** so passing both is a parse error.

| Flag | Effect |
|---|---|
| `--color` | Force ANSI color even when stderr is not a TTY (`Console(force_terminal=True)`) |
| `--no-color` | Disable ANSI color even when stderr is a TTY (`Console(no_color=True)`) |
| (default) | Auto-detect via Rich's built-in TTY detection |

Precedence: CLI flags override env vars. Rich already respects `NO_COLOR` and `FORCE_COLOR` env vars natively as fallback when neither flag is given.

### What stays the same

- Stdout is never colored (final answer only, must stay pipe-safe).
- `-q` / `--quiet` still suppresses all verbose output, **including think logs** (matching current code and tests).
- Error and warning messages still always print regardless of `-q`.
- Skill discovery warnings remain verbose-only (current behavior).
- All output caps, truncation limits, and content stay identical.

### New dependency

Add `rich` to `pyproject.toml` dependencies. Rich is widely used, well-maintained, pure Python, and handles all the TTY/color/encoding edge cases we'd otherwise have to write ourselves (Windows terminals, `NO_COLOR`, fallback for dumb terminals, Unicode width, etc.).

## Implementation order

1. Add `rich` to `pyproject.toml` dependencies and `fmt.py` to the wheel `only-include` list. Run `uv sync`.
2. Create `fmt.py` with a module-level `Console(stderr=True)`, an `init()` function to reconfigure it from CLI flags, and all the helper functions listed above. Use `rich.markup.escape()` / `Text()` for all dynamic content.
3. Add `--color` / `--no-color` as a mutually exclusive argparse group in `agent.py`.
4. Replace `log()` calls and direct `print(..., file=sys.stderr)` calls in `agent.py` with `fmt.*()` calls, keeping `if verbose:` guards.
5. Replace the `print(...)` line in `thinking.py:_log()` with `fmt.think_step()`, keeping normalization/truncation and the verbose guard intact.
6. Replace warnings in `skills.py` with `fmt.warning()` / `fmt.info()`.
7. Delete the `log()` function from `agent.py`.
8. Update existing tests:
   - `tests/test_logging.py`: Add `color=False` and `no_color=False` to every `SimpleNamespace` args fixture so that `main()` can read `args.color` / `args.no_color` without `AttributeError`. Update string assertions to match new formatted output — assert on the semantic content (e.g. "Turn", "read_file", "Agent finished") rather than exact formatting, since Rich output includes escape codes. The `test_quiet_e2e_no_stderr` test stays unchanged (add the two color fields) and continues to assert empty stderr.
   - `tests/test_think.py`: `TestAgentLogSkip.test_think_skips_generic_log` — monkeypatch target changes from `agent.log` to the relevant `fmt.*` functions. Think format tests (`test_newline_normalization`, `test_revision_log_format`, `test_branch_log_format`) stay valid since `_log()` still owns normalization; just update string matching if the output wrapper changes. `test_quiet_no_stderr` stays unchanged.
9. Write new tests in `tests/test_fmt.py`: verify output content with `Console(file=StringIO(), no_color=True)`, test markup escaping of bracket-containing text, test each helper produces expected text.
10. Run full test suite (`uv run python -m pytest tests/ -v`) and fix any regressions.
11. Manual smoke test with a real agent run.

## Non-goals

- No progress bars or spinners (the LLM call is blocking and we don't know duration upfront).
- No changes to stdout formatting.
- No full Rich panels/tables/trees for tool output — keep it line-oriented with just color, rules, and markup.
