<!-- STATUS: COMPLETE -->

# Phase 18: Film Noir Theme and Narrative

## Goal

Make the theme system fully plugin-compatible: a new theme is a new directory with data files, zero code changes. This requires two things:

1. **LLM prompts templatized** — The mission generation prompts currently hardcode "Film Noir" language. Convert them to use template variables filled from theme data, so a different theme produces different narrative without touching `prompts.py`.
2. **TUI text themed** — Every user-facing string uses vocabulary and aliases from `ThemeConfig` rather than hardcoded text.

Also addresses deferred items from Phase 17 UA testing.

## Dependencies

- Phase 7 (mission generation prompts) — prompt templates being modified
- Phase 16 (TUI layout, widgets, commands) — widget skeleton
- Phase 17 (TUI screens, interaction flows) — command handlers

## Deferred Items Incorporated

- **Themed language in TUI prompts** *(deferred 2026-03-18)*: Welcome messages and hint text use canonical names ("next", "complete") instead of themed aliases ("investigate", "file report"). **Addressed in Task 6.**
- **Command reference panel shows canonical names** *(deferred 2026-03-18)*: Right panel should show themed aliases with short descriptions. Full canonical reference moves to `help` command. **Addressed in Task 7.**
- **Show IN_PROGRESS missions in the `next` list** *(deferred 2026-03-18)*: Missions already IN_PROGRESS should appear in the list, visually marked as active. **Addressed in Task 9.**

---

## Research Notes

### Theme Architecture (Requirements §7)

A theme is a configuration package with 5 components:
1. **TCSS stylesheet** — Done (Phase 16, `noir.tcss`, 353 lines)
2. **Command aliases** — Done (Phase 16, `theme.py`, 10 aliases)
3. **Vocabulary** — Defined but NOT threaded through TUI code
4. **Narrative tone** — NOT externalized (hardcoded in `missions/prompts.py`)
5. **Sidekick personas** — Future feature (v1.1+)

### Hardcoded Film Noir in LLM Prompts

`missions/prompts.py` has Film Noir hardcoded in two places:

**System prompt** (`MISSION_SYSTEM_PROMPT`):
```
## Film Noir Theming

All mission titles and narrative text should use Film Noir detective language:
- Titles: "The Password Vault", "Shadows in the Login Flow", "Following the Data Trail"
- Narrative intros: Set a moody, investigative tone
- Narrative outros: Provide closure like a case being solved
```

Also in chains section:
```
- NOT the Film Noir themed `title` values
```

**Human prompt** (`MISSION_HUMAN_PROMPT`):
```
- Use Film Noir theming for titles and narrative
```

**Briefing prompts** (`briefing/prompts.py`): NO theme language — intentionally "plain language, no theme applied" per requirements. Leave untouched.

### Call Chain for Theme Threading

```
orchestrator.create_campaign()
  → run_mission_pipeline(analysis, briefing, model, ...)
    → generate_category_missions_multi(analysis, briefing, category, model, ...)
      → chain.invoke(input_data)  # input_data feeds MISSION_PROMPT template
```

Template variables in `input_data` currently: `{category}`, `{briefing_summary}`, `{category_context}`, `{module_descriptions}`, `{previous_missions}`.

New template variables needed: `{theme_name}`, `{theme_tone}`, `{theme_title_examples}`, `{theme_intro_style}`, `{theme_outro_style}`.

### NarrativeConfig Design

Rather than a single `narrative_tone: str` blob, use a structured dataclass so theme authors know exactly what to provide and validation can catch missing fields:

```python
@dataclass(frozen=True)
class NarrativeConfig:
    """Theme-specific narrative instructions for LLM prompt injection."""
    tone: str              # General tone/genre description
    title_examples: list[str]  # 3-5 example themed titles
    intro_style: str       # How to write narrative_intro fields
    outro_style: str       # How to write narrative_outro fields
```

Fallback strategy: if any NarrativeConfig field is empty, the prompt section degrades to generic "investigation" language rather than crashing. The `build_theme_prompt_section()` helper handles this.

### Current Vocabulary (from `film_noir/theme.py`)

```python
_VOCABULARY = {
    "mission": "lead", "missions": "leads",
    "chain": "case thread", "chains": "case threads",
    "campaign": "case", "campaigns": "cases",
    "category": "precinct", "categories": "precincts",
    "complete": "close", "completed": "closed",
    "available": "open", "locked": "sealed",
    "in_progress": "active", "prerequisite": "lead-up",
    "prerequisites": "lead-ups",
    "file_ref": "evidence", "file_refs": "evidence",
}
```

### Where Hardcoded TUI Text Needs Theming

1. **`app.py` on_mount**: "Type **next** to see them" — should use alias
2. **`app.py` error messages**: "No mission in progress. Use 'next'" — mixed canonical/themed
3. **`app.py` _handle_unknown**: "Type 'help' or 'call in a marker'" — hardcodes both
4. **`content.py` show_missions**: hint "Type 'next \<N\>'" — should use alias
5. **`content.py` show_status**: "Lead Status", "By Precinct" — correct words but hardcoded
6. **`content.py` show_completion**: "Lead Closed" — correct but hardcoded
7. **`content.py` show_help**: Shows canonical + alias, needs man-page redesign
8. **`command_ref.py`**: Shows `canonical | alias` — should show alias + description

### TUI Threading Strategy

**Pass ThemeConfig to presentation widgets.** ContentArea and CommandReference receive `ThemeConfig` and call `theme.vocab()` / `theme.alias()` to resolve display text. This preserves presentation separation — theme is presentation config, not data.

`ConfirmModal` text is constructed in `app.py` and passed as strings, so `app.py` themes those strings using `self.theme_config`. No changes to `ConfirmModal` itself.

---

## Tasks

### Task 1: Add NarrativeConfig and helper methods to ThemeConfig `DONE`

**Files:** `src/unfamiliar/tui/themes/__init__.py`

**Done when:** `ThemeConfig` has `narrative` field and helper methods; `NarrativeConfig` is importable; helpers return fallback for unknown keys.

**Action:**
- Create `NarrativeConfig` frozen dataclass with fields: `tone: str`, `title_examples: list[str]`, `intro_style: str`, `outro_style: str`
- Add `narrative: NarrativeConfig` field to `ThemeConfig`
- Add helper methods to `ThemeConfig`:
  - `vocab(key: str) -> str` — returns `vocabulary.get(key, key)`
  - `alias(key: str) -> str` — returns `aliases.get(key, key)`
- Export `NarrativeConfig` from `__init__.py`

**Verify:** `python3 -c "from unfamiliar.tui.themes import ThemeConfig, NarrativeConfig; print('OK')"`

**Done when:** `ThemeConfig` has `narrative` field and helper methods; `NarrativeConfig` is importable; helpers return fallback for unknown keys.

---

### Task 2: Populate Film Noir narrative data `IN_PROGRESS`

**Files:** `src/unfamiliar/tui/themes/film_noir/theme.py`

**Action:**
- Create `_NARRATIVE` constant as a `NarrativeConfig` with Film Noir-specific data:
  - `tone`: "Film Noir detective investigation. Moody, atmospheric, hardboiled. The developer is a detective investigating an unfamiliar codebase."
  - `title_examples`: `["The Password Vault", "Shadows in the Login Flow", "Following the Data Trail", "The Dead Drop in the Config", "Stakeout on the Event Loop"]`
  - `intro_style`: "Set a moody, investigative tone. The detective discovers a new lead, finds a clue, or gets a tip from an informant."
  - `outro_style`: "Provide closure like a case being solved. The detective pieces together what they've learned, connecting it to the bigger picture."
- Wire `narrative=_NARRATIVE` into `FILM_NOIR_THEME` construction

**Verify:** `python3 -c "from unfamiliar.tui.themes import get_theme; t = get_theme('film_noir'); print(t.narrative.tone[:50]); print(len(t.narrative.title_examples))"`

**Done when:** Film Noir theme has complete narrative configuration; data matches what's currently hardcoded in `prompts.py`.

---

### Task 3: Templatize mission prompts with theme variables `DONE`

**Files:** `src/unfamiliar/llm/missions/prompts.py`

**Action:**
- Replace the hardcoded "## Film Noir Theming" section in `MISSION_SYSTEM_PROMPT` with template variables:
  ```
  ## {theme_name} Theming

  All mission titles and narrative text should use {theme_name} language:
  - Example titles: {theme_title_examples}
  - Narrative intros: {theme_intro_style}
  - Narrative outros: {theme_outro_style}
  ```
- Replace the Film Noir reference in the chains section:
  - `"NOT the Film Noir themed"` → `"NOT the {theme_name} themed"`
- Replace the Film Noir reference in `MISSION_HUMAN_PROMPT`:
  - `"Use Film Noir theming"` → `"Use {theme_name} theming"`
- Add a module-level helper function `build_theme_prompt_vars(theme_config) -> dict[str, str]` that:
  - Accepts a `ThemeConfig` (or `NarrativeConfig` + display name)
  - Returns a dict with keys: `theme_name`, `theme_title_examples`, `theme_intro_style`, `theme_outro_style`
  - **Fallback**: If narrative fields are empty/None, returns generic defaults:
    - `theme_name` → `"Investigation"` (fallback)
    - `theme_title_examples` → `"Descriptive, engaging titles that reflect the investigation"` (fallback)
    - `theme_intro_style` → `"Set the scene for the investigation"` (fallback)
    - `theme_outro_style` → `"Summarize what was learned and connect it to the bigger picture"` (fallback)
- `MISSION_PROMPT` remains a `ChatPromptTemplate` — the new variables are just additional template slots alongside `{category}`, `{briefing_summary}`, etc.

**Verify:** `python3 -c "from unfamiliar.llm.missions.prompts import MISSION_PROMPT, build_theme_prompt_vars; print(MISSION_PROMPT.input_variables)"` — should include new theme variables. Also `build_theme_prompt_vars(None)` returns fallback dict without crashing.

**Done when:** `MISSION_SYSTEM_PROMPT` and `MISSION_HUMAN_PROMPT` have no hardcoded "Film Noir" references; `build_theme_prompt_vars` produces correct dicts from theme data and from None/empty fallback.

---

### Task 4: Thread theme through the pipeline call chain `IN_PROGRESS`

**Files:** `src/unfamiliar/llm/missions/generator.py`, `src/unfamiliar/llm/missions/pipeline.py`, `src/unfamiliar/campaign/orchestrator.py`, `src/unfamiliar/llm/missions/__init__.py`

**Action:**
- `generator.py`: Add `theme_name: str = "Investigation"` and `narrative: NarrativeConfig | None = None` parameters to both `generate_category_missions()` and `generate_category_missions_multi()`
  - Import `build_theme_prompt_vars` from `prompts.py`
  - Add theme variables to `input_data` dict: `input_data.update(build_theme_prompt_vars(...))`
  - Use the new parameters to build theme vars; fall back to defaults if None
- `pipeline.py`: Add `theme_name: str = "Investigation"` and `narrative: NarrativeConfig | None = None` parameters to `run_mission_pipeline()`
  - Pass through to `generate_category_missions_multi()` calls
- `orchestrator.py`: Load `ThemeConfig` from the campaign's theme field
  - Pass `theme_name=theme_config.display_name` and `narrative=theme_config.narrative` to `run_mission_pipeline()`
  - Import `get_theme` from `unfamiliar.tui.themes`
- `__init__.py`: Update re-exports if any signatures changed in public API

**Verify:** `python3 -c "from unfamiliar.llm.missions.pipeline import run_mission_pipeline; import inspect; print(inspect.signature(run_mission_pipeline))"` — should show `theme_name` and `narrative` parameters.

**Done when:** Theme data flows from `orchestrator → pipeline → generator → prompt template`; all new parameters have safe defaults so existing callers (including tests) don't break.

---

### Task 5: Pass ThemeConfig to ContentArea and CommandReference

**Files:** `src/unfamiliar/tui/app.py`, `src/unfamiliar/tui/widgets/content.py`, `src/unfamiliar/tui/widgets/command_ref.py`

**Action:**
- Add `theme: ThemeConfig` parameter to `ContentArea.__init__`; store as `self.theme`
- Add `theme: ThemeConfig` parameter to `CommandReference.__init__`; store as `self.theme`
- Update `UnfamiliarApp.compose()` to pass `self.theme_config` to both widgets
- Ensure widgets still function correctly with the new parameter (no rendering changes yet)

**Verify:** `python3 -c "from unfamiliar.tui.widgets.content import ContentArea"` — no import errors. Existing tests still pass.

**Done when:** Both widgets accept and store ThemeConfig; app passes it during composition; no regressions.

---

### Task 6: Thread vocabulary and aliases through TUI text

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

**Action — content.py:** Replace all hardcoded themed/canonical terms with vocabulary lookups via `self.theme.vocab()`:

- `show_missions()`:
  - Header: `f"{self.theme.vocab('available').title()} {self.theme.vocab('missions').title()}"` → "Open Leads"
  - Empty state: use vocabulary for "missions", "prerequisites"

- `show_mission_detail()`:
  - Status label values: use vocabulary for status strings
  - Section headers: "Evidence Files" → `self.theme.vocab('file_refs').title()`; "Prerequisites" → `self.theme.vocab('prerequisites').title()`

- `show_status()`:
  - Status breakdown labels: vocabulary for "available", "in_progress", "locked", "completed"
  - Section headers: vocabulary for "categories", "chains"

- `show_completion()`:
  - Header: `f"{self.theme.vocab('mission').title()} {self.theme.vocab('completed').title()}"` → "Lead Closed"
  - Unlocked section: vocabulary for "missions"

**Action — app.py:** Replace canonical command names in user-facing strings with themed aliases via `self.theme_config.alias()`:

- `on_mount()` welcome messages:
  - "Type **mission** to review" → `f"Type **{self.theme_config.alias('mission')}** to review"`
  - "Type **complete** to close it" → use alias
  - "Type **next** to see them" → use alias
  - "Type **status** to see the case board" → use alias + vocabulary

- `_handle_next()`, `_handle_complete()`, `_handle_mission()` error messages: use aliases
- `_handle_unknown()`: use `self.theme_config.alias('help')`
- `_close_campaign()`: "Case Closed" → vocabulary; "Leads closed" → vocabulary
- `_confirm_quit()`: "active lead" → vocabulary
- `_handle_end_campaign()`: confirmation message uses vocabulary

- Hint text in `show_missions()`: use aliases for "next" and "mission" commands

**Verify:** Run existing tests. Visually verify that Film Noir terms appear correctly (vocabulary maps to the same words already hardcoded, so output shouldn't change).

**Done when:** Every user-facing string in `content.py` and `app.py` resolves terms from theme config, not hardcoded strings.

---

### Task 7: Redesign command reference panel

**Files:** `src/unfamiliar/tui/widgets/command_ref.py`

**Action:**
- Change `CommandReference._update_children()` to show **alias + short description** instead of `canonical | alias`
- Format: alias on first line (bold accent), description below (dim) — compact two-line entries
- Remove canonical name from the panel display entirely
- Canonical names are only visible in the `help` command output

**Verify:** Visual inspection — right panel shows themed aliases with descriptions.

**Done when:** Command reference panel shows only themed aliases with short descriptions.

---

### Task 8: Redesign help command — man-page style

**Files:** `src/unfamiliar/tui/widgets/content.py`

**Action:**
- Rewrite `show_help()` to render a comprehensive man-page style reference:
  - Title: "UNFAMILIAR — {theme.vocab('campaign').title()} Commands"
  - Two sections:
    1. **Commands**: Each entry shows themed alias (bold), canonical name (dim parenthetical), description
    2. **Keyboard shortcuts**: `q` (quit), `?` (help), `Escape` (cancel/defocus)
- This is the full reference that the slimmed-down command panel no longer provides

**Verify:** Type `help` (or `call in a marker`) in TUI — content area shows man-page style reference with both themed and canonical names.

**Done when:** Help shows comprehensive reference with themed aliases prominently and canonical names as parenthetical.

---

### Task 9: Show IN_PROGRESS missions in `next` list

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

**Action:**
- `queries.py`: Create `load_actionable_missions(db, campaign_id) -> list[MissionSummary]` that returns both IN_PROGRESS and AVAILABLE missions, with IN_PROGRESS first
- `content.py` `show_missions()`: When rendering, check `mission.status` — if `in_progress`, prefix with themed status marker (e.g., `[active]` using `self.theme.vocab('in_progress')`)
- `app.py` `_handle_next()`: Change from `load_available_missions` to `load_actionable_missions`
- `app.py` `_start_mission()`: If mission is already IN_PROGRESS, skip the `start_mission()` call (it would throw `MissionStatusError`) and just set it as current and show detail
- Update hint text at bottom of mission list to reflect that in-progress missions are shown

**Verify:** Start a mission, type `next` again — the in-progress mission appears at the top marked as active. Selecting it sets it as current without error.

**Done when:** `next` shows both available and in-progress missions; in-progress missions are visually distinguished; selecting an in-progress mission resumes it without error.

---

## Interface Contracts

### NarrativeConfig (new)

```python
@dataclass(frozen=True)
class NarrativeConfig:
    """Theme-specific narrative instructions for LLM prompt injection."""
    tone: str
    title_examples: list[str]
    intro_style: str
    outro_style: str
```

- **Invariant:** All fields are non-empty strings/lists for a valid theme
- **Edge case:** Empty `title_examples` → fallback helper produces generic text

### ThemeConfig (updated)

```python
@dataclass(frozen=True)
class ThemeConfig:
    name: str
    display_name: str
    css_path: Path
    aliases: dict[str, str]
    vocabulary: dict[str, str]
    narrative: NarrativeConfig

    def vocab(self, key: str) -> str:
        """Get themed vocabulary term, fallback to key itself."""

    def alias(self, key: str) -> str:
        """Get themed command alias, fallback to key itself."""
```

- **Invariant:** `vocab()` and `alias()` never raise — always return a string
- **Edge case:** Unknown key → returns the key unchanged
- **Edge case:** Empty string key → returns empty string

### build_theme_prompt_vars (new)

```python
def build_theme_prompt_vars(
    theme_config: ThemeConfig | None = None,
) -> dict[str, str]:
    """Build template variable dict for mission prompt from theme config.

    Returns dict with keys: theme_name, theme_title_examples,
    theme_intro_style, theme_outro_style.

    Falls back to generic investigation language if theme_config is None
    or any narrative field is empty.
    """
```

- **Invariant:** Always returns a dict with all 4 keys populated
- **Invariant:** Never raises — None input produces generic fallback
- **Edge case:** ThemeConfig with empty narrative fields → per-field fallback (non-empty fields still used)
- **Return type:** `dict[str, str]` — values are plain strings ready for prompt template injection

### load_actionable_missions (new)

```python
def load_actionable_missions(
    db: CampaignDB, campaign_id: int
) -> list[MissionSummary]:
    """Load missions the user can act on (in-progress + available).

    Returns IN_PROGRESS missions first, then AVAILABLE.
    """
```

- **Invariant:** Result contains only IN_PROGRESS or AVAILABLE missions
- **Invariant:** IN_PROGRESS missions always come before AVAILABLE in the list
- **Edge case:** No missions of either status → returns empty list

### Updated Pipeline Signatures

```python
def run_mission_pipeline(
    analysis, briefing_result, model, campaign_dir,
    token_budget=DEFAULT_TOKEN_BUDGET,
    provider_name=None,
    category_cooldown=0.0,
    categories=None,
    theme_name="Investigation",        # NEW
    narrative=None,                     # NEW: NarrativeConfig | None
) -> PipelineResult: ...

def generate_category_missions_multi(
    analysis, briefing_result, category, model,
    previous_missions=None,
    token_budget=DEFAULT_TOKEN_BUDGET,
    provider_name=None,
    missions_per_call=3,
    max_calls=3,
    theme_name="Investigation",        # NEW
    narrative=None,                     # NEW: NarrativeConfig | None
) -> CategoryMissionsResponse: ...
```

- **Invariant:** New parameters have safe defaults — existing callers and tests work unchanged
- **Invariant:** When `narrative=None`, prompts use generic investigation language (no theme-specific text)

### ContentArea (updated)

```python
class ContentArea(Widget):
    def __init__(self, theme: ThemeConfig, *, name=None, id=None, classes=None): ...
    # All show_* methods unchanged in signature but use self.theme internally
```

### CommandReference (updated)

```python
class CommandReference(Widget):
    def __init__(self, theme: ThemeConfig, commands=None, *, name=None, id=None, classes=None): ...
    # _update_children shows alias + description (not canonical)
```
