# Task Recon: Implement Rich progress display with spinner during async operation polling

**Task ID:** 23.3
**Status:** pending
**Generated:** 2026-01-29
**Agent:** claude-opus-4-5-20251101

---

## Task Summary

Add progress display during the async polling phase of assign/unassign operations using Rich's Status context manager, showing operation status updates via the on_progress callback from NumberService.poll_operation.

### Parent Task Context

**Parent Task:** Implement Numbers Assign/Unassign CLI Commands (ID: 23)
**Parent Goal:** Add assign and unassign commands to the numbers CLI with progress display and confirmation prompts.
**This Subtask's Role:** Enhance user experience by providing visual feedback during the async polling phase of assign/unassign operations. Currently these operations execute silently; this subtask adds spinner display and status updates.

### Dependencies
- Depends on: 23.1 (assign command - DONE), 23.2 (unassign command - DONE)

### Acceptance Criteria
From task details:
1. Spinner display during wait=True operations
2. No spinner in JSON mode
3. Status updates on poll callbacks showing operation status (notStarted, running, etc.)
4. Success/failure/timeout messages display correctly
5. --no-wait shows operation ID without spinner

---

## Insights from Previous Tasks

### Dependency Task Notes
| Task ID | Title | Key Insight |
|---------|-------|-------------|
| 23.1 | Implement assign command | `AssignServices` pattern bundles multiple services; `_run_assign_async` returns (result, formatter) tuple |
| 23.2 | Implement unassign command | Uses simpler `_run_async` since only NumberService needed; confirmation and dry-run patterns established |

### Reusable Patterns from Dependencies
- **AssignServices pattern**: Implemented in `src/teams_phone/cli/numbers.py:72-85` during Task 23.1
- **Confirmation flow**: See `numbers.py:583-595` (assign) and `numbers.py:735-745` (unassign)
- **Status formatting**: `_format_operation_status()` at `numbers.py:427-436`
- **JSON mode handling**: Check `cli_ctx.json_output` to skip Rich output

### Warnings and Gotchas
- `_run_assign_async` returns `(result, formatter)` tuple - when coroutine returns a tuple, unpack like: `(a, b), _ = _run_assign_async(...)`
- Phone numbers in Rich console output may contain ANSI escape codes even with --no-color
- ANSI escape codes in test output: Use partial string matching instead of exact matches

### Open Items Inherited
- [ ] Subtask 23.3: Add progress display for long-running operations (THIS TASK)

---

## Architecture Context

### Relevant Design Decisions
- **Layered monolith**: CLI layer (`cli/`) calls services layer (`services/`) which abstracts Graph API
- **Async operations pattern**: Graph API assign/unassign returns 202 + Location header, requires polling
- **OutputFormatter**: Already imports Rich's `Progress`, `SpinnerColumn`, `TextColumn` - provides `progress()` context manager for progress bars

### Module Structure
```
src/teams_phone/
├── cli/
│   └── numbers.py       # Where spinner integration goes (assign/unassign commands)
├── services/
│   └── number_service.py # poll_operation accepts on_progress callback
├── infrastructure/
│   └── output_formatter.py # Has Rich imports, progress() method
└── models/
    └── number.py        # AsyncOperation, OperationStatus enums
```

### Conventions to Follow
- JSON mode check: `if cli_ctx.json_output:` to skip Rich display
- Error formatting: Use `formatter.error()` with remediation
- Success formatting: Use `formatter.success()` with green checkmark
- Service calls wrapped in `_run_async` or `_run_assign_async` helpers

---

## Related Code References

### Primary References (Must Read)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/cli/numbers.py` | 461-641 | assign command - integration point for spinner |
| `src/teams_phone/cli/numbers.py` | 660-787 | unassign command - integration point for spinner |
| `src/teams_phone/services/number_service.py` | 198-249 | poll_operation method with on_progress callback |
| `src/teams_phone/infrastructure/output_formatter.py` | 145-179 | Existing progress() context manager pattern |

### Secondary References (Helpful Context)
| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/models/number.py` | 76-93 | OperationStatus enum values |
| `src/teams_phone/cli/context.py` | 19-57 | CLIContext with json_output, get_output_formatter() |

### Code Patterns to Follow

```python
# From output_formatter.py:145-179 - Existing progress pattern (for reference, but Status is simpler)
@contextmanager
def progress(
    self,
    total: int,
    description: str = "Processing",
) -> Iterator[Progress]:
    if self.json_mode:
        # Return a minimal progress that does nothing in JSON mode
        progress = Progress(
            console=Console(force_terminal=False, quiet=True),
            disable=True,
        )
        # ...
```

```python
# From number_service.py:198-249 - poll_operation signature
async def poll_operation(
    self,
    operation_id: str,
    *,
    timeout: int = 60,
    on_progress: Callable[[AsyncOperation], None] | None = None,
) -> AsyncOperation:
    # on_progress callback receives AsyncOperation on each poll iteration
    if on_progress is not None:
        on_progress(operation)
```

```python
# Rich Status pattern from documentation
from rich.console import Console
console = Console()
with console.status("Working...", spinner="dots") as status:
    # work happens here
    status.update("Still working...")  # Update text dynamically
```

---

## Dependencies

### Internal Modules
- `teams_phone.cli.context` - CLIContext with json_output flag
- `teams_phone.infrastructure.output_formatter` - OutputFormatter with console attribute
- `teams_phone.models.number` - OperationStatus enum, AsyncOperation model
- `teams_phone.services.number_service` - NumberService.poll_operation with on_progress

### External Packages
- `rich` - Already installed, provides Console.status() context manager

### New Dependencies Needed
- None required - Rich is already a dependency

---

## Testing Guidance

### Test File Location
`tests/unit/test_cli_numbers.py`

### Test Patterns in Use
```python
# From tests/unit/test_cli_numbers.py:1016-1065 - Testing --no-wait mode
@pytest.mark.unit
def test_assign_no_wait_shows_operation_id(self) -> None:
    """assign --no-wait shows operation ID."""
    mock_operation = _make_operation(status=OperationStatus.NOT_STARTED)
    # ... setup mocks ...
    result = runner.invoke(app, [..., "--no-wait"])
    assert result.exit_code == 0
    assert "op-" in result.stdout
    assert "initiated" in result.stdout
```

```python
# From tests/unit/test_output_formatter.py:345-377 - Testing progress context
@pytest.mark.unit
def test_progress_works_in_json_mode(self) -> None:
    """progress() returns context manager in json_mode (disabled)."""
    formatter = OutputFormatter(console=console, json_mode=True)
    with formatter.progress(total=10, description="Testing") as progress:
        assert progress is not None
```

### Fixtures Available
- `_make_operation()` - Creates AsyncOperation for testing (line 692-702)
- `_make_number()` - Creates NumberAssignment for testing (line 34-58)
- `_make_user()` - Creates UserConfiguration for testing (line 660-676)

### Test Utilities
- `typer.testing.CliRunner` - For invoking CLI commands
- `unittest.mock.AsyncMock` - For mocking async service methods
- `unittest.mock.patch` - For patching services and infrastructure

---

## Risks and Blockers

### Technical Risks
- [ ] **Spinner/console interaction**: Rich's Status may not work well with async code. Need to ensure the spinner updates correctly within the async context. Mitigation: The on_progress callback is called synchronously from within poll_operation, so updating Status should work.
- [ ] **Threading concerns**: Rich's Live/Status use threading internally. Since poll_operation runs in asyncio.run(), the status updates happen on the main thread which should be safe.

### Dependency Risks
- None identified - all required dependencies (23.1, 23.2) are complete

### Open Questions
- [ ] Should the spinner text show elapsed time or just status? (Recommendation: keep simple with status only for MVP)
- [ ] Should timeout errors show a special message vs generic error? (Task details specify: yes, show operation ID so user can check later)

---

## Implementation Recommendations

### Suggested Approach
1. **Add a progress helper function** in `numbers.py` that wraps the spinner logic:
   - Create a function/class that manages Rich Status context
   - Accept formatter and cli_ctx to check json_output
   - Provide method to update status text from on_progress callback

2. **Modify assign command** (lines 597-607):
   - Currently calls `services.number_service.assign_number(...)` directly
   - Wrap with progress display when `wait=True` and `not cli_ctx.json_output`
   - Pass on_progress callback to poll_operation via assign_number

3. **Modify unassign command** (lines 747-755):
   - Currently calls `service.unassign_number(...)` directly
   - Same pattern as assign

4. **Handle edge cases**:
   - JSON mode: Skip all spinner/status display
   - --no-wait: No spinner needed (current behavior is fine)
   - Timeout: Catch TimeoutError, display message with operation ID

### Files to Create/Modify
| Action | File | Reason |
|--------|------|--------|
| Modify | `src/teams_phone/cli/numbers.py` | Add progress helper and integrate with assign/unassign |
| Modify | `tests/unit/test_cli_numbers.py` | Add tests for spinner display |

### Quick Wins
- Start by adding the helper function for status display
- Test with assign command first (more complex, validates pattern)
- Apply same pattern to unassign

---

## Cheat Sheet

### Key Imports
```python
# Already in numbers.py:
from teams_phone.models import AsyncOperation, OperationStatus

# May need to add for Status:
from rich.console import Console
# OR use formatter.console directly (already available)
```

### Key Functions to Use
- `formatter.console.status("message", spinner="dots")` - Create spinner context
- `status.update("new message")` - Update spinner text
- `OperationStatus.SUCCEEDED/FAILED/SKIPPED` - Terminal states
- `OperationStatus.NOT_STARTED/RUNNING` - In-progress states
- `_format_operation_status(status)` - Format status for display (already exists at line 427)

### Common Patterns
- **JSON mode check**: `if cli_ctx.json_output:` skip Rich output
- **Status formatting**: `_format_operation_status(operation.status)` returns human-readable string
- **Console access**: `formatter.console` provides Rich Console instance

### OperationStatus Values
```python
class OperationStatus(str, Enum):
    NOT_STARTED = "notStarted"    # Non-terminal
    RUNNING = "running"           # Non-terminal
    SUCCEEDED = "succeeded"       # Terminal - success
    FAILED = "failed"             # Terminal - failure
    SKIPPED = "skipped"           # Terminal - warning
```

### Proposed Progress Helper Pattern
```python
def _run_with_progress(
    formatter: OutputFormatter,
    coro: Coroutine,
    initial_message: str = "Starting operation...",
) -> Any:
    """Run async operation with progress spinner."""
    if formatter.json_mode:
        return asyncio.run(coro)

    status_holder = {"current": None}

    def on_progress(operation: AsyncOperation) -> None:
        if status_holder["current"]:
            status_holder["current"].update(
                f"Waiting for operation... (status: {operation.status.value})"
            )

    # ... integrate with assign_number/unassign_number calls
```

---

*Generated by task-recon (claude-opus-4-5-20251101) | Ready for implementation*
