Goal
When a user selects a model without a configured provider API key, do not dead-end. Instead, open an inline API key entry modal, let the user save the key, and immediately continue model switching.
Final Decisions (No Open Questions)
- Config source policy: keep current behavior. API keys come from
session.user_config["env"] persisted to ~/.config/tunacode.json. We are not adding os.environ fallback in this task.
- Recovery UX scope: no provider dashboard/signup URLs in this task.
- Picker failure behavior: if key entry is cancelled, user returns to chat (not back into picker). This is acceptable for this iteration.
- Entry point: inject key-entry flow only into
/model picker callback path (on_model_selected).
- Error renderer: map
AuthenticationError in ui/renderers/errors.py with API-key-specific recovery hints.
- Secret handling: key must never be logged, notified, or rendered after entry.
- UI styling: reuse existing
#api-key-input and #error-label styles in modals.tcss; no CSS file changes required.
- Implementation order: complete quick wins first (config path + error renderer), then new screen, then wiring, then tests.
Non-goals
os.environ key support
- Provider dashboard links in model registry schema
- Compaction key UX (
MissingCompactionApiKeyError)
- Any agent loop, CI, deployment, or observability changes
Preconditions (Run Before Editing)
- Read
.claude/skills/neXTSTEP-ui/SKILL.md (required by project rule for UI work).
- Confirm baseline behavior manually once:
- Run
uv run tunacode
- Execute
/model
- Pick a provider/model with missing key
- Observe current dead-end behavior (toast + no model switch)
Files To Change
src/tunacode/ui/commands/model.py
src/tunacode/ui/renderers/errors.py
src/tunacode/ui/screens/api_key_entry.py (new)
tests/unit/ui/test_api_key_entry.py (new)
tests/unit/ui/test_error_renderer_authentication.py (new)
Execution Plan (Sequential, Junior Safe)
Step 1 - Always show config path on picker validation failure
File: src/tunacode/ui/commands/model.py
Target symbol: on_model_selected callback inside ModelCommand.execute
Change:
- In
_validate_provider_api_key_with_notification(...) call inside on_model_selected, change show_config_path=False to show_config_path=True.
Why first:
- One-line low-risk improvement; immediate user guidance even before inline entry screen exists.
Done when:
- Selecting a model without key logs both missing env var and config file path in
rich_log.
Step 2 - Add AuthenticationError recovery mapping
File: src/tunacode/ui/renderers/errors.py
Target symbols: ERROR_SEVERITY_MAP, DEFAULT_RECOVERY_COMMANDS
Changes:
- Add
"AuthenticationError": "error" in ERROR_SEVERITY_MAP.
- Add
"AuthenticationError" entry in DEFAULT_RECOVERY_COMMANDS with explicit API-key recovery actions:
/model # Pick model and enter API key
tunacode --setup # Re-run guided setup
cat ~/.config/tunacode.json # Verify env key is present
Important:
- Do not add broad generic auth messaging. Keep commands concrete and actionable.
Done when:
render_exception(AuthenticationError("bad key")) yields severity error and includes API-key recovery commands.
Step 3 - Create ApiKeyEntryScreen
File: src/tunacode/ui/screens/api_key_entry.py (new)
Create class:
class ApiKeyEntryScreen(Screen[bool | None])
Constructor contract:
- Inputs:
provider_id: str
state_manager: StateManager
- Store both on
self.
UI composition (must include):
- Title static (clear context: user must configure key)
- Static showing provider (
provider_id) and required env var (get_provider_env_var(provider_id))
Input(password=True, id="api-key-input")
Static("", id="error-label")
- Save button (
id="save-button") and Cancel button (id="cancel-button")
Behavior:
- Escape key cancels (
dismiss(None)).
- Save action:
- Read and
strip() input
- If empty: update
#error-label with clear message and return
- Resolve env var with
get_provider_env_var(provider_id)
- Update
state_manager.session.user_config["env"][env_var] = api_key
- Persist with
save_config(state_manager)
dismiss(True)
- Cancel action:
dismiss(None)
Error path:
- If
save_config raises, catch exception and surface message in #error-label; do not dismiss.
Security rules:
- Never show key value in
notify, log, or error text.
Done when:
- Screen mounts correctly, validates empty input, saves non-empty key, and dismisses with
True or None.
Step 4 - Wire ApiKeyEntryScreen into model picker flow
File: src/tunacode/ui/commands/model.py
Target symbol: nested on_model_selected callback in ModelCommand.execute
Required refactor (keep scope local to callback):
- Extract current successful model-switch block into a local helper function inside
execute (same closure), e.g. apply_model_selection(full_model: str) -> None.
- In
on_model_selected(full_model):
- Return immediately on
None (unchanged)
- Run
_validate_provider_api_key_with_notification(..., show_config_path=True)
- If validation passes: call
apply_model_selection(full_model)
- If validation fails:
- Derive
provider_id = full_model.split(":", 1)[0]
- Push
ApiKeyEntryScreen(provider_id, state_manager)
- In its dismiss callback:
- If result is
True, call apply_model_selection(full_model)
- If result is
None, no-op
Do not change:
- Direct
/model provider:model flow behavior (except Step 1 parity already done)
- Agent cache invalidation order in successful switch path
Done when:
- Picker flow with missing key becomes:
- provider -> model -> key screen -> save -> model switched + resource bar updated
Step 5 - Add focused tests (new files)
5A. Screen behavior test
File: tests/unit/ui/test_api_key_entry.py (new)
Cases:
- Empty input + save shows inline validation error and does not dismiss.
- Valid input + save writes key into
session.user_config["env"] and dismisses True.
- Cancel dismisses
None.
Notes for implementation:
- Follow existing async Textual test style (
async with app.run_test(headless=True) as pilot:).
- Use
TextualReplApp(state_manager=StateManager()) harness and push the screen.
5B. Error renderer auth mapping test
File: tests/unit/ui/test_error_renderer_authentication.py (new)
Cases:
render_exception(AuthenticationError("bad key")) returns PanelMeta severity error.
- Rendered panel includes at least one API-key recovery command (
/model or tunacode --setup).
Test helper:
- Define a local test exception class:
class AuthenticationError(Exception): pass
- This avoids relying on provider SDK imports in unit tests.
Done when:
- Both new test files pass.
Manual Verification Checklist (Required)
Run once after Step 4 and after tests:
uv run tunacode
/model
- Select provider/model with missing key
- Confirm inline API key screen appears
- Press cancel:
- No crash
- Model remains unchanged
- Re-open
/model, select same model, enter valid key, save:
- Model switches
- Success notification appears (
Model: ...)
- Restart app, run
/model and verify provider now validates without missing-key toast (key persisted)
Commands To Run (Exact)
uv run pytest tests/unit/ui/test_api_key_entry.py -q
uv run pytest tests/unit/ui/test_error_renderer_authentication.py -q
uv run ruff check src/tunacode/ui/commands/model.py src/tunacode/ui/renderers/errors.py src/tunacode/ui/screens/api_key_entry.py tests/unit/ui/test_api_key_entry.py tests/unit/ui/test_error_renderer_authentication.py
Risks and Guardrails
| Risk |
Guardrail |
| Key screen callback runs after picker dismissal |
Expected in Textual callback chain; treat cancel as no-op and return to chat |
| Secret leakage via logs |
Never interpolate key value into messages; only log env var names |
| Save failure leaves user stuck |
Keep user on key screen and show explicit error in #error-label |
Regression in existing direct /model provider:model path |
Do not modify that branch except existing validation helper call parity |
Definition of Done
All are true:
- Missing-key picker flow no longer dead-ends.
- User can enter key inline and complete model switch without restarting app.
AuthenticationError renders with API-key-specific recovery commands.
- New unit tests pass.
- Ruff check passes on touched files.
Handoff Note
This plan is ready for junior execution as written. No architectural decisions remain open for this scope.
1---2name: final-decisions-no-open-questions-23description: When a user selects a model without a configured provider API key, do not dead-end. Instead, open an inline API key entry modal, let the user save the key, and immediately continue model switching.4---56## Goal78When a user selects a model without a configured provider API key, do **not** dead-end. Instead, open an inline API key entry modal, let the user save the key, and immediately continue model switching.910## Final Decisions (No Open Questions)11121. **Config source policy:** keep current behavior. API keys come from `session.user_config["env"]` persisted to `~/.config/tunacode.json`. We are **not** adding `os.environ` fallback in this task.132. **Recovery UX scope:** no provider dashboard/signup URLs in this task.143. **Picker failure behavior:** if key entry is cancelled, user returns to chat (not back into picker). This is acceptable for this iteration.154. **Entry point:** inject key-entry flow only into `/model` picker callback path (`on_model_selected`).165. **Error renderer:** map `AuthenticationError` in `ui/renderers/errors.py` with API-key-specific recovery hints.176. **Secret handling:** key must never be logged, notified, or rendered after entry.187. **UI styling:** reuse existing `#api-key-input` and `#error-label` styles in `modals.tcss`; no CSS file changes required.198. **Implementation order:** complete quick wins first (config path + error renderer), then new screen, then wiring, then tests.2021## Non-goals2223- `os.environ` key support24- Provider dashboard links in model registry schema25- Compaction key UX (`MissingCompactionApiKeyError`)26- Any agent loop, CI, deployment, or observability changes2728## Preconditions (Run Before Editing)29301. Read `.claude/skills/neXTSTEP-ui/SKILL.md` (required by project rule for UI work).312. Confirm baseline behavior manually once:32 - Run `uv run tunacode`33 - Execute `/model`34 - Pick a provider/model with missing key35 - Observe current dead-end behavior (toast + no model switch)3637## Files To Change38391. `src/tunacode/ui/commands/model.py`402. `src/tunacode/ui/renderers/errors.py`413. `src/tunacode/ui/screens/api_key_entry.py` (new)424. `tests/unit/ui/test_api_key_entry.py` (new)435. `tests/unit/ui/test_error_renderer_authentication.py` (new)4445## Execution Plan (Sequential, Junior Safe)4647---4849### Step 1 - Always show config path on picker validation failure5051**File:** `src/tunacode/ui/commands/model.py`52**Target symbol:** `on_model_selected` callback inside `ModelCommand.execute`5354**Change:**55- In `_validate_provider_api_key_with_notification(...)` call inside `on_model_selected`, change `show_config_path=False` to `show_config_path=True`.5657**Why first:**58- One-line low-risk improvement; immediate user guidance even before inline entry screen exists.5960**Done when:**61- Selecting a model without key logs both missing env var and config file path in `rich_log`.6263---6465### Step 2 - Add AuthenticationError recovery mapping6667**File:** `src/tunacode/ui/renderers/errors.py`68**Target symbols:** `ERROR_SEVERITY_MAP`, `DEFAULT_RECOVERY_COMMANDS`6970**Changes:**711. Add `"AuthenticationError": "error"` in `ERROR_SEVERITY_MAP`.722. Add `"AuthenticationError"` entry in `DEFAULT_RECOVERY_COMMANDS` with explicit API-key recovery actions:73 - `/model # Pick model and enter API key`74 - `tunacode --setup # Re-run guided setup`75 - `cat ~/.config/tunacode.json # Verify env key is present`7677**Important:**78- Do not add broad generic auth messaging. Keep commands concrete and actionable.7980**Done when:**81- `render_exception(AuthenticationError("bad key"))` yields severity `error` and includes API-key recovery commands.8283---8485### Step 3 - Create ApiKeyEntryScreen8687**File:** `src/tunacode/ui/screens/api_key_entry.py` (new)8889**Create class:**90- `class ApiKeyEntryScreen(Screen[bool | None])`9192**Constructor contract:**93- Inputs:94 - `provider_id: str`95 - `state_manager: StateManager`96- Store both on `self`.9798**UI composition (must include):**991. Title static (clear context: user must configure key)1002. Static showing provider (`provider_id`) and required env var (`get_provider_env_var(provider_id)`)1013. `Input(password=True, id="api-key-input")`1024. `Static("", id="error-label")`1035. Save button (`id="save-button"`) and Cancel button (`id="cancel-button"`)104105**Behavior:**106- Escape key cancels (`dismiss(None)`).107- Save action:108 1. Read and `strip()` input109 2. If empty: update `#error-label` with clear message and return110 3. Resolve env var with `get_provider_env_var(provider_id)`111 4. Update `state_manager.session.user_config["env"][env_var] = api_key`112 5. Persist with `save_config(state_manager)`113 6. `dismiss(True)`114- Cancel action: `dismiss(None)`115116**Error path:**117- If `save_config` raises, catch exception and surface message in `#error-label`; do not dismiss.118119**Security rules:**120- Never show key value in `notify`, log, or error text.121122**Done when:**123- Screen mounts correctly, validates empty input, saves non-empty key, and dismisses with `True` or `None`.124125---126127### Step 4 - Wire ApiKeyEntryScreen into model picker flow128129**File:** `src/tunacode/ui/commands/model.py`130**Target symbol:** nested `on_model_selected` callback in `ModelCommand.execute`131132**Required refactor (keep scope local to callback):**1331. Extract current successful model-switch block into a local helper function inside `execute` (same closure), e.g. `apply_model_selection(full_model: str) -> None`.1342. In `on_model_selected(full_model)`:135 - Return immediately on `None` (unchanged)136 - Run `_validate_provider_api_key_with_notification(..., show_config_path=True)`137 - If validation passes: call `apply_model_selection(full_model)`138 - If validation fails:139 1. Derive `provider_id = full_model.split(":", 1)[0]`140 2. Push `ApiKeyEntryScreen(provider_id, state_manager)`141 3. In its dismiss callback:142 - If result is `True`, call `apply_model_selection(full_model)`143 - If result is `None`, no-op144145**Do not change:**146- Direct `/model provider:model` flow behavior (except Step 1 parity already done)147- Agent cache invalidation order in successful switch path148149**Done when:**150- Picker flow with missing key becomes:151 - provider -> model -> key screen -> save -> model switched + resource bar updated152153---154155### Step 5 - Add focused tests (new files)156157#### 5A. Screen behavior test158159**File:** `tests/unit/ui/test_api_key_entry.py` (new)160161**Cases:**1621. Empty input + save shows inline validation error and does not dismiss.1632. Valid input + save writes key into `session.user_config["env"]` and dismisses `True`.1643. Cancel dismisses `None`.165166**Notes for implementation:**167- Follow existing async Textual test style (`async with app.run_test(headless=True) as pilot:`).168- Use `TextualReplApp(state_manager=StateManager())` harness and push the screen.169170#### 5B. Error renderer auth mapping test171172**File:** `tests/unit/ui/test_error_renderer_authentication.py` (new)173174**Cases:**1751. `render_exception(AuthenticationError("bad key"))` returns `PanelMeta` severity `error`.1762. Rendered panel includes at least one API-key recovery command (`/model` or `tunacode --setup`).177178**Test helper:**179- Define a local test exception class:180 - `class AuthenticationError(Exception): pass`181- This avoids relying on provider SDK imports in unit tests.182183**Done when:**184- Both new test files pass.185186---187188## Manual Verification Checklist (Required)189190Run once after Step 4 and after tests:1911921. `uv run tunacode`1932. `/model`1943. Select provider/model with missing key1954. Confirm inline API key screen appears1965. Press cancel:197 - No crash198 - Model remains unchanged1996. Re-open `/model`, select same model, enter valid key, save:200 - Model switches201 - Success notification appears (`Model: ...`)2027. Restart app, run `/model` and verify provider now validates without missing-key toast (key persisted)203204## Commands To Run (Exact)2052061. `uv run pytest tests/unit/ui/test_api_key_entry.py -q`2072. `uv run pytest tests/unit/ui/test_error_renderer_authentication.py -q`2083. `uv run ruff check src/tunacode/ui/commands/model.py src/tunacode/ui/renderers/errors.py src/tunacode/ui/screens/api_key_entry.py tests/unit/ui/test_api_key_entry.py tests/unit/ui/test_error_renderer_authentication.py`209210## Risks and Guardrails211212| Risk | Guardrail |213|------|-----------|214| Key screen callback runs after picker dismissal | Expected in Textual callback chain; treat cancel as no-op and return to chat |215| Secret leakage via logs | Never interpolate key value into messages; only log env var names |216| Save failure leaves user stuck | Keep user on key screen and show explicit error in `#error-label` |217| Regression in existing direct `/model provider:model` path | Do not modify that branch except existing validation helper call parity |218219## Definition of Done220221All are true:2222231. Missing-key picker flow no longer dead-ends.2242. User can enter key inline and complete model switch without restarting app.2253. `AuthenticationError` renders with API-key-specific recovery commands.2264. New unit tests pass.2275. Ruff check passes on touched files.228229## Handoff Note230231This plan is ready for junior execution as written. No architectural decisions remain open for this scope.