<!-- STATUS: COMPLETE -->

All 8 tasks completed. All 903 tests pass. Linting clean.

# Phase 17: TUI — Screens and Interaction Flows

## Goal

Wire the TUI command handlers into working interaction flows. Phase 16 built layout, widgets, command routing, and content rendering. Phase 17 makes the commands actually *do things*: select missions, start them, complete them, resume sessions, and close campaigns — with proper state tracking, error handling, and user feedback.

## Dependencies

- Phase 16 (TUI layout, widgets, command routing) — complete
- Phase 13 (campaign orchestration — `start_mission`, `complete_mission`, `unlock_mission`) — complete
- Phase 15 (pipeline output quality) — complete

## Research Notes

### Textual ModalScreen for confirmations
- `ModalScreen` prevents parent app key bindings from being processed
- Semi-transparent background shows previous screen underneath
- `dismiss(result)` returns data to parent via callback
- Pattern: `self.push_screen(ConfirmScreen(), callback_fn)`
- CSS needed: Grid layout for dialog, background overlay

### Textual notify() for transient feedback
- `self.notify(message, title, severity, timeout)` — toast notifications
- Severity levels: `information`, `warning`, `error`
- Rich markup supported in messages
- Thread-safe, non-blocking
- Good for: "Mission started", "Lead closed — 2 new leads unlocked"

### Mission numbering strategy
- `show_missions()` already renders `[1] Title` numbering
- App stores `_mission_list_cache: list[MissionSummary]` populated on `next`
- `next 3` resolves to `_mission_list_cache[2].id` → calls `start_mission()`
- `mission 3` resolves same way but just shows detail (no status change)
- Cache invalidated on any status-changing command

### Multiple IN_PROGRESS missions
- Requirements don't restrict to one active mission at a time
- `current_mission_id` is UI tracking (what's displayed), not a constraint
- Multiple missions can be IN_PROGRESS in DB simultaneously
- Resume flow picks up the most recent IN_PROGRESS mission
- Matches ADHD-friendly design: user can switch between missions freely

### Bug: complete_mission return type
- `complete_mission()` returns `tuple[int, list[int]]` (plain tuple)
- `app.py` accesses `result.unlocked_ids` — would crash at runtime
- `CompletionResult` dataclass exists in `campaign/types.py` with `mission_id` and `unlocked_ids` fields
- Fix: change `complete_mission()` to return `CompletionResult`

---

## Tasks

### Task 1: Fix complete_mission return type [DONE]

**Files:** `src/unfamiliar/campaign/missions.py`

**Action:**
- Change `complete_mission()` return annotation from `tuple[int, list[int]]` to `CompletionResult`
- Add import for `CompletionResult` from `campaign.types`
- Change return statement to `return CompletionResult(mission_id=mission_id, unlocked_ids=unlocked_ids)`

**Verify:** `python3 -m pytest tests/ -q --tb=short` — all 903 tests pass. `ruff check src/unfamiliar/campaign/missions.py` clean.

**Done when:** `complete_mission()` returns a `CompletionResult` dataclass and all existing tests pass.

---

### Task 2: Mission list cache and next command selection [DONE]

**Files:** `src/unfamiliar/tui/app.py`

**Action:**
- Add `_mission_list_cache: list[MissionSummary] = []` attribute to `UnfamiliarApp.__init__`
- Import `start_mission` from `campaign.missions` and `MissionStatusError`
- Modify `_handle_next(args)`:
  - No args: query available missions, store in `_mission_list_cache`, render list
  - With number arg: validate index against cache, call new `_start_mission()` helper
- Create `_start_mission(mission_id: int)` helper:
  - Call `start_mission(db, mission_id)`
  - Set `self.current_mission_id = mission_id`
  - Load and show mission detail (with narrative_intro)
  - `self.notify()` with themed "Mission started" message
  - Update header
  - Catch `MissionStatusError` → show error

**Verify:** Seed campaign → launch TUI → `next` shows list → `next 1` starts first mission → header updates → `mission` shows current mission detail.

**Done when:** `next` shows available missions. `next <N>` starts mission N from the displayed list, transitions it to IN_PROGRESS, sets it as current, and shows its detail.

---

### Task 3: Enhance mission command with number argument [DONE]

**Files:** `src/unfamiliar/tui/app.py`, `src/unfamiliar/tui/commands.py`

**Action:**
- Update `CommandDef` for "mission" to `accepts_args=True`
- Modify `_handle_mission(args)`:
  - No args: show current active mission (existing behavior)
  - With number arg: look up in `_mission_list_cache`, show detail WITHOUT starting
  - If no cache: show error "Run 'next' first to see available leads."
  - If number out of range: show error with valid range
- Update hint text in `ContentArea.show_missions()`: change "Use 'mission <number>' to view details." to "Type 'next <N>' to start a lead, or 'mission <N>' to peek."

**Verify:** `next` → see list → `mission 2` shows detail of #2 without changing status → `mission` (no args) still shows "No active lead" if none started.

**Done when:** `mission <N>` views any mission from the last list without starting it. `mission` alone shows the current active mission.

---

### Task 4: Session resume flow [DONE]

**Files:** `src/unfamiliar/tui/app.py`, `src/unfamiliar/tui/queries.py`

**Action:**
- Add `load_in_progress_missions` to the imports in `app.py` (already exists in queries.py)
- Enhance `on_mount()`:
  - After loading campaign and updating header/commands:
  - Query IN_PROGRESS missions via `load_in_progress_missions(db, campaign_id)`
  - If any found: set `current_mission_id` to the first one's ID
  - Show contextual welcome:
    - Has in-progress: "Welcome back, Detective. You were investigating: {title}. Type 'mission' to review, or 'complete' to close it."
    - No in-progress but available: "Welcome back, Detective. You have {N} leads waiting. Type 'next' to see them."
    - No in-progress and no available: "Welcome back, Detective. All leads are closed or locked. Type 'status' to see the case board."
  - Check campaign completion: if all missions completed, show completion message

**Verify:** Start a mission → quit TUI → relaunch → welcome message references the in-progress mission and `current_mission_id` is set.

**Done when:** TUI resumes with correct state — in-progress missions are recognized, current mission is auto-set, and welcome message reflects actual campaign state.

---

### Task 5: End campaign confirmation modal [DONE]

**Files:** `src/unfamiliar/tui/screens/__init__.py` (new), `src/unfamiliar/tui/screens/confirm.py` (new), `src/unfamiliar/tui/app.py`, `src/unfamiliar/tui/themes/film_noir/noir.tcss`

**Action:**
- Create `tui/screens/__init__.py` with exports
- Create `tui/screens/confirm.py`:
  - `ConfirmModal(ModalScreen[bool])` — generic confirmation dialog
  - Accepts `message: str` and `confirm_label: str` in `__init__`
  - Composes: Grid with Label (message), two Buttons (confirm + cancel)
  - `on_button_pressed`: dismiss(True) or dismiss(False)
- Add modal CSS to `noir.tcss`:
  - `ConfirmModal` background with alpha overlay
  - `#dialog` grid with Film Noir styling (dark background, amber border, themed text colors)
  - Button styles matching Film Noir palette
- Update `_handle_end_campaign(args)`:
  - Push `ConfirmModal` with message "Close the case? This ends the investigation."
  - Callback: if confirmed, update campaign status to CLOSED via `db.campaign_repo.update()`, show summary stats in content area, notify, call `self.call_later(3.0, self.exit)`
  - If cancelled: notify "Case remains open."

**Verify:** `end campaign` → modal appears with confirmation → Cancel returns to app → Confirm shows summary and exits.

**Done when:** End campaign has a confirmation step, updates campaign status in DB, shows a summary, and exits gracefully.

---

### Task 6: Notifications for state changes [DONE]

**Files:** `src/unfamiliar/tui/app.py`

**Action:**
- Replace `ContentArea.show_text()` calls for transient feedback with `self.notify()`:
  - Mission started: `self.notify(f"Lead started: {title}", title="On the case")`
  - Unlock success: `self.notify(f"Unlocked: {title}", title="Lock picked")`
  - Unknown command: keep in content area (it's not transient)
- In `_handle_complete`: after showing completion in content area, also `self.notify()` with unlock count
- Keep content area for detailed views (missions list, detail, status, briefing, completion summary) — these are reference content the user scrolls through
- Notifications for quick confirmations only

**Verify:** Start a mission → toast appears briefly → content area shows mission detail. Complete → content shows completion view + toast with unlock count.

**Done when:** State-change confirmations use toast notifications. Content area reserved for detailed views.

---

### Task 7: Error handling and header refresh audit [DONE]

**Files:** `src/unfamiliar/tui/app.py`

**Action:**
- Import `MissionStatusError` from `campaign.missions`
- Wrap `_start_mission()`, `_handle_complete()`, `_handle_unlock()` with proper `MissionStatusError` catching:
  - Show the error's message (which is user-friendly)
  - Don't expose tracebacks
- Audit all status-changing handlers ensure header refresh:
  - `_handle_next` (when starting): refresh header ✓ (via `_start_mission`)
  - `_handle_complete`: refresh header ✓ (already does)
  - `_handle_unlock`: refresh header ✓ (already does)
  - `_handle_end_campaign`: no header refresh needed (exiting)
- Fix `_handle_unlock` exception handling: replace bare `except Exception` with `except MissionStatusError`
- Clear `_mission_list_cache` after any status change (complete, unlock, start) since availability may have changed

**Verify:** Try to complete when no mission selected → friendly error. Try to start a locked mission → friendly error. Complete a mission → header numbers update → `next` shows updated available list.

**Done when:** All command handlers catch specific exceptions, show friendly errors, refresh header after status changes, and invalidate the mission list cache.

---

### Task 8: Quit confirmation and clean shutdown [DONE]

**Files:** `src/unfamiliar/tui/app.py`

**Action:**
- Modify `action_quit()` and `_handle_quit()`:
  - If `current_mission_id` is not None, push a `ConfirmModal` with "You have an active lead. Quit anyway? Your progress is saved."
  - If no current mission, exit immediately
  - Callback: if confirmed, exit; if cancelled, notify "Staying on the case."
- Ensure `on_unmount()` properly closes DB connection (already does)

**Verify:** Start a mission → press `q` → confirmation dialog → Cancel keeps you in → Confirm exits. No active mission → `q` exits immediately.

**Done when:** Quit prompts for confirmation only when a mission is in progress. DB connection is properly closed in all exit paths.

---

## Interface Contracts

### `complete_mission(db, mission_id) -> CompletionResult`
- **Signature:** `def complete_mission(db: CampaignDB, mission_id: int) -> CompletionResult`
- **Purpose:** Mark a mission as completed and unlock eligible dependents
- **Invariants:** Returns `CompletionResult` with `mission_id` (int) and `unlocked_ids` (list[int], may be empty)
- **Valid inputs:** `mission_id` that exists and is AVAILABLE or IN_PROGRESS
- **Error conditions:** `MissionStatusError` if mission is LOCKED, COMPLETED, or not found
- **Edge cases:**
  - Mission with no dependents → `unlocked_ids = []`
  - Mission whose dependents have other unmet prereqs → those stay LOCKED, not in `unlocked_ids`

### `UnfamiliarApp._start_mission(mission_id: int) -> None`
- **Signature:** `def _start_mission(self, mission_id: int) -> None`
- **Purpose:** Transition a mission to IN_PROGRESS, set as current, show its detail
- **Invariants:** After call, `self.current_mission_id == mission_id`, mission status is IN_PROGRESS in DB
- **Valid inputs:** Any mission ID that exists and is AVAILABLE (or IN_PROGRESS for re-selecting)
- **Error conditions:** `MissionStatusError` if mission LOCKED or COMPLETED — caught and shown as error
- **Edge cases:**
  - User already has a different mission in progress → old stays IN_PROGRESS, current switches to new
  - Mission is already IN_PROGRESS → `start_mission()` is a no-op transition (AVAILABLE → IN_PROGRESS), but the mission is already IN_PROGRESS so need to handle this — just set current and show detail without calling `start_mission` again

### `ConfirmModal(ModalScreen[bool])`
- **Signature:** `def __init__(self, message: str, confirm_label: str = "Confirm") -> None`
- **Purpose:** Generic reusable confirmation dialog
- **Invariants:** Always dismisses with `True` (confirm) or `False` (cancel)
- **Valid inputs:** Any string for message and confirm_label
- **Edge cases:**
  - Escape key → dismiss(False) (cancel behavior)
  - Empty message → renders empty label (shouldn't happen but safe)

### `UnfamiliarApp._mission_list_cache`
- **Type:** `list[MissionSummary]`
- **Purpose:** Maps display numbers (1-indexed) to mission IDs for `next <N>` and `mission <N>` commands
- **Invariants:** Populated by `_handle_next()` with no args. Index N-1 corresponds to display number N. Cleared on any status-changing command.
- **Edge cases:**
  - Empty cache when user types `next 3` before `next` → error "Run 'next' first"
  - Number out of range → error with valid range
  - Cache stale after complete/unlock → cleared, user must re-run `next`

---

## Deferred Items

- **Campaign overwrite confirmation** (from `deferred.md`): This happens at `create_campaign` level in CLI, before TUI launches. Not TUI scope — defer to Phase 20 (polish).
- **Campaign completion summary generation** (LLM call): The end campaign handler shows stats from DB but doesn't generate the LLM completion summary described in requirements. That's a separate feature requiring an LLM call, defer to Phase 20.
