//! The eye-declare v2 app: the FSM is the model, `update` is the driver
//! loop, and the live tail is a view over the not-yet-committed turns.

use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::time::{Duration, Instant};

use atuin_client::settings::Settings;
use crossterm::event::{KeyCode, KeyEventKind};
use eye_declare::{
    App, Ctx, Element, ElementExt, Fluent, Focus, FocusHandle, InputEvent, Keymap, Task, col, key,
    keymap, text,
};
use ratatui_core::style::{Color, Modifier, Style};
use tokio::sync::mpsc::UnboundedSender;
use tui_textarea::TextArea;

use crate::fsm::effects::{Effect, ExitAction, PermissionTarget, TimeoutKind};
use crate::fsm::events::{Event, PermissionChoice, PermissionResponse};
use crate::fsm::tools::ToolPreviewData;
use crate::fsm::{AgentFsm, AgentState, StreamPhase};
use crate::tools::{ClientToolCall, PermissibleToolCall};
use crate::tui::events::PermissionResult;
use crate::tui::persist::PersistJob;
use crate::tui::recall::RecallState;
use crate::tui::select::{SelectMsg, SelectState};
use crate::tui::slash::{SlashCommandRegistry, SlashCommandSearchResult};
use crate::tui::state::ConversationEvent;
use crate::tui::tips::{Tip, TipContext, TipRotation};
use crate::tui::view;
use crate::tui::view::turn::{TurnBuilder, UiTurn, UiTurnKind};
use crate::usage::UsageSnapshot;

/// What the TUI resolves to, for the shell hook.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum ExitOutcome {
    Execute(String),
    Insert(String),
    #[default]
    Cancel,
}

/// IO resources the app's effects need. Not part of the FSM's state.
/// Session persistence goes through the [`crate::tui::persist`] worker,
/// which owns the `SessionManager`.
pub struct IoContext {
    pub app_ctx: crate::context::AppContext,
    pub client_ctx: crate::context::ClientContext,
    pub persist: UnboundedSender<PersistJob>,
    pub file_tracker: crate::file_tracker::FileReadTracker,
    pub edit_permissions: crate::edit_permissions::EditPermissionCache,
    pub snapshot_store: Option<crate::snapshots::SnapshotStore>,
    pub skill_registry: crate::skills::SkillRegistry,
    pub user_context_cache: crate::user_context::UserContextCache,
}

/// The policy vocabulary. Mode-dependent resolution happens in `keymap()`
/// (rebuilt from the model every update), so each variant means exactly one
/// thing by the time it reaches `update`.
#[derive(Debug, Clone)]
pub enum Msg {
    /// Unclaimed key/paste, routed to the editor.
    Input(InputEvent),
    /// Insert a newline (Shift+Enter / Ctrl+J).
    Newline,
    /// Submit the editor's text (Enter with non-blank input).
    Submit,
    /// Accept the top slash suggestion (Tab while suggesting).
    AcceptSlashSuggestion,
    /// Run the suggested command, or confirm a dangerous one (Enter).
    ExecuteCommand,
    /// Exit with the suggested command inserted, not executed (Tab).
    InsertCommand,
    /// Move the permission-prompt cursor (Up/Down while asking).
    PermissionSelect(SelectMsg),
    /// Answer the permission prompt with the highlighted option (Enter).
    ConfirmPermission,
    /// Cancel: generation, or a pending confirmation (Esc).
    Cancel,
    /// Interrupt a running tool execution (Ctrl+C / Esc while executing).
    Interrupt,
    /// Retry after an error (Enter / r).
    Retry,
    /// Leave the TUI without a command.
    Quit(ExitOutcome),
    /// Move the model-picker cursor (Up/Down while picking).
    ModelSelect(SelectMsg),
    /// Choose the highlighted model (Enter while picking).
    ConfirmModel,
    /// An FSM event from spawned work (stream frames, timeouts).
    Fsm(Event),
    /// Fresh credit-usage snapshot. Never reaches the FSM.
    Usage(UsageSnapshot),
    /// The background usage refresh failed; logged, never shown.
    UsageFetchFailed(String),
}

pub struct AiApp {
    pub fsm: AgentFsm,
    /// `None` only in headless tests; every effect that needs IO degrades
    /// to a debug log without it.
    io: Option<IoContext>,
    /// The in-flight stream turn; dropping it (Esc, replacement) cancels
    /// the HTTP request.
    streaming: Option<Task>,
    /// Interrupt senders for executing shell tools. Firing one kills the
    /// child process; the detached tool stream still reports its outcome.
    tool_interrupts: HashMap<String, tokio::sync::oneshot::Sender<()>>,
    /// Cursor for the permission prompt, reset when the asked tool changes.
    permission_select: SelectState,
    /// Which tool the current prompt (and cursor) belongs to.
    permission_prompt_for: Option<String>,
    /// Cursor for the /model picker, reset whenever the picker closes.
    model_select: SelectState,
    in_git_project: bool,
    /// Submitted by `init` as if typed (the `atuin ai "question"` path).
    initial_prompt: Option<String>,
    /// Whether `init` should refresh usage in the background.
    usage_stale: bool,
    /// Latest known credit usage (cached at startup, refreshed from Done
    /// frames). Rendered by the status bar (pickers slice).
    usage: Option<UsageSnapshot>,
    /// "Continuing previous session…" banner, frozen at startup.
    resume_notice: Option<String>,
    /// The editor as a plain model value; see `view::input` for why RefCell.
    input: RefCell<TextArea<'static>>,
    /// Up/Down recall of this session's submitted messages.
    recall: RecallState,
    /// The editor's focus handle (keymap fallthrough is focus-scoped).
    /// Handles share their `Focus`'s cell via Arc, so the factory itself
    /// isn't retained; re-add one when a second focusable appears.
    input_focus: FocusHandle,
    slash_registry: SlashCommandRegistry,
    skill_names: HashSet<String>,
    slash_results: Vec<SlashCommandSearchResult>,
    /// Conversation events already committed to scrollback via `ctx.push`.
    pushed_events: usize,
    /// Turns already committed — 0 means the next turn is the first (no
    /// leading blank row).
    pushed_turns: usize,
    exiting: bool,
    /// Config snapshot from startup; tip relevance predicates read it.
    settings: Settings,
    tips: TipRotation,
    /// When the in-flight turn started; survives continuation streams.
    turn_started_at: Option<Instant>,
    /// The tip pulled for the in-flight turn, shown under the spinner.
    turn_tip: Option<&'static Tip>,
    /// The last turn's clean completion — "Responded in …" plus its tip.
    /// Cleared when a new turn starts and on /new.
    responded: Option<Responded>,
}

/// A completed turn's summary, rendered where the spinner used to be.
struct Responded {
    elapsed: Duration,
    tip: Option<&'static Tip>,
}

impl AiApp {
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        fsm: AgentFsm,
        io: IoContext,
        resume_notice: Option<String>,
        slash_registry: SlashCommandRegistry,
        skill_names: HashSet<String>,
        usage: Option<UsageSnapshot>,
        initial_prompt: Option<String>,
        usage_stale: bool,
        settings: Settings,
    ) -> Self {
        Self {
            in_git_project: io.app_ctx.git_root.is_some(),
            io: Some(io),
            usage,
            initial_prompt,
            usage_stale,
            settings,
            tips: TipRotation::new(),
            ..Self::headless(fsm, resume_notice, slash_registry, skill_names)
        }
    }

    /// An app without IO: effects that need it degrade to debug logs.
    /// Tests drive stream/timeout flows by processing `Msg::Fsm` directly.
    #[cfg_attr(not(test), allow(dead_code))]
    fn headless(
        fsm: AgentFsm,
        resume_notice: Option<String>,
        slash_registry: SlashCommandRegistry,
        skill_names: HashSet<String>,
    ) -> Self {
        let input_focus = Focus::new().handle();
        input_focus.focus();
        Self {
            fsm,
            io: None,
            streaming: None,
            tool_interrupts: HashMap::new(),
            permission_select: SelectState::default(),
            permission_prompt_for: None,
            model_select: SelectState::default(),
            in_git_project: false,
            initial_prompt: None,
            usage_stale: false,
            usage: None,
            resume_notice,
            input: RefCell::new(view::input::new_textarea()),
            recall: RecallState::default(),
            input_focus,
            slash_registry,
            skill_names,
            slash_results: Vec::new(),
            pushed_events: 0,
            pushed_turns: 0,
            exiting: false,
            settings: Settings::utc(),
            tips: TipRotation::starting_at(0),
            turn_started_at: None,
            turn_tip: None,
            responded: None,
        }
    }

    fn is_busy(&self) -> bool {
        matches!(self.fsm.state, AgentState::Turn { .. })
    }

    fn has_confirmation(&self) -> bool {
        matches!(self.fsm.state, AgentState::Idle {
            confirmation: Some(_)
        })
    }

    fn input_active(&self) -> bool {
        matches!(self.fsm.state, AgentState::Idle { .. }) && !self.has_confirmation()
    }

    fn is_input_blank(&self) -> bool {
        self.input.borrow().is_empty()
    }

    /// A shell command is executing (its interrupt sender is live). This —
    /// not preview visibility — is what Ctrl+C/Esc policy keys on: a
    /// command exists from the moment it's spawned, before any output.
    fn shell_executing(&self) -> bool {
        !self.tool_interrupts.is_empty()
    }

    /// Whether the visible conversation carries a suggested command.
    fn has_command(&self) -> bool {
        self.visible_events().iter().any(|e| e.as_command().is_some())
    }

    fn visible_events(&self) -> &[crate::tui::state::ConversationEvent] {
        let events = &self.fsm.ctx.events;
        let start = self.fsm.ctx.view_start_index.min(events.len());
        &events[start..]
    }

    fn footer_text(&self) -> &'static str {
        match &self.fsm.state {
            AgentState::Idle { confirmation: None } => {
                if self.has_command() && self.is_input_blank() {
                    "[Enter] Execute suggested command  [Tab] Insert Command"
                } else {
                    "[Enter] Send  [Shift+Enter] New line  [Esc] Exit"
                }
            }
            AgentState::Idle {
                confirmation: Some(_),
            } => "[Enter] Confirm dangerous command  [Esc] Cancel",
            AgentState::Turn { .. } => "[Esc] Cancel",
            AgentState::Error(_) => "[Enter]/[r] Retry  [Esc] Exit",
        }
    }

    /// Turns not yet committed to scrollback, built fresh from the FSM's
    /// events past the pushed frontier.
    fn live_turns(&self) -> Vec<UiTurn> {
        let events = &self.fsm.ctx.events;
        let start = self.fsm.ctx.view_start_index.max(self.pushed_events).min(events.len());
        let mut builder = TurnBuilder::new(&self.fsm.ctx.tools);
        for event in &events[start..] {
            builder.add_event(event);
        }
        // Streaming text lives in current_response until the FSM commits it
        // on stream end; inject it so the live turn shows it as it arrives.
        let streaming_text = self.fsm.ctx.current_response.trim_start();
        if !streaming_text.is_empty() {
            builder.add_event(&ConversationEvent::Text {
                content: streaming_text.to_string(),
            });
        }
        builder.build()
    }

    /// Recompute slash suggestions from the editor's text.
    fn refresh_slash(&mut self) {
        let query = {
            let input = self.input.borrow();
            let text = input.lines().join("\n");
            text.strip_prefix('/').map(str::to_string)
        };
        match query {
            Some(query) => {
                let mut results = self.slash_registry.search_fuzzy(&query);
                results.sort_by(|a, b| {
                    b.relevance.partial_cmp(&a.relevance).unwrap_or(std::cmp::Ordering::Equal)
                });
                self.slash_results = results;
            }
            None => self.slash_results.clear(),
        }
    }

    /// Feed an event to the FSM and act on its effects.
    fn handle_fsm(&mut self, event: Event, ctx: &mut Ctx<'_, Self>) {
        if let Event::ToolExecutionDone { tool_id, .. } = &event {
            self.tool_interrupts.remove(tool_id);
        }
        let was_busy = self.is_busy();
        let effects = self.fsm.handle(event);
        tracing::trace!(?effects, state = ?self.fsm.state, "FSM transition");
        if !was_busy && self.is_busy() {
            self.turn_started_at = Some(Instant::now());
            self.responded = None;
            self.turn_tip = self.pull_tip();
        }
        // The event list only shrinks when the FSM resets the session
        // (/new archives and clears it). What was pushed stays in
        // scrollback; the frontier restarts for the new list — a stale
        // frontier would slice away new events entirely (invisible /new
        // notice; a streamed response vanishing when its text commits
        // into the sliced-off region on stream end).
        if self.pushed_events > self.fsm.ctx.events.len() {
            self.pushed_events = 0;
        }
        for effect in effects {
            match effect {
                Effect::ExitApp(action) => {
                    let exit = match action {
                        ExitAction::Execute(cmd) => ExitOutcome::Execute(cmd),
                        ExitAction::Insert(cmd) => ExitOutcome::Insert(cmd),
                        ExitAction::Cancel => ExitOutcome::Cancel,
                    };
                    // The runtime presents once more after an exit-requesting
                    // update, and finalize reclaims the rows the tail vacated —
                    // so hiding the input and exiting in the same update leaves
                    // the screen without it. No deferred render needed.
                    self.exiting = true;
                    ctx.exit(exit);
                }
                Effect::StartStream {
                    messages,
                    session_id,
                } => self.start_stream(messages, session_id, ctx),
                // Dropping the Task drops the bridge's generator, which
                // drops the HTTP stream. Esc-cancels-generation is this.
                Effect::AbortStream => self.streaming = None,
                Effect::Persist => self.persist(),
                Effect::TurnEnded => {
                    let elapsed =
                        self.turn_started_at.take().map(|t| t.elapsed()).unwrap_or_default();
                    self.responded = Some(Responded {
                        elapsed,
                        tip: self.turn_tip.take(),
                    });
                }
                Effect::ArchiveSession => {
                    self.responded = None;
                    self.turn_tip = None;
                    if let Some(io) = &self.io {
                        let _ = io.persist.send(PersistJob::Archive);
                    }
                }
                Effect::ScheduleTimeout {
                    timeout_id,
                    duration,
                    kind,
                } => {
                    // Stale firings are guarded by timeout_id in the FSM,
                    // so a detached sleep is safe.
                    let event = match kind {
                        TimeoutKind::Confirmation => Event::ConfirmationTimeout { timeout_id },
                        TimeoutKind::ToolExecution { tool_id } => Event::ToolExecutionTimeout {
                            timeout_id,
                            tool_id,
                        },
                    };
                    ctx.perform(async move {
                        tokio::time::sleep(duration).await;
                        Msg::Fsm(event)
                    })
                    .detach();
                }
                Effect::CheckPermission { tool_id, tool } => {
                    self.check_permission(tool_id, tool, ctx);
                }
                Effect::ResolveOutputCommand {
                    tool_id,
                    history_id,
                } => {
                    let Some(io) = &self.io else {
                        continue;
                    };
                    let db = io.app_ctx.history_db.clone();
                    ctx.perform(async move {
                        // Ids are stored in simple (no-hyphen) form today,
                        // but older or imported rows may be hyphenated.
                        let command = match db.load(&history_id.as_simple().to_string()).await {
                            Ok(Some(h)) => Some(h.command),
                            _ => db
                                .load(&history_id.as_hyphenated().to_string())
                                .await
                                .ok()
                                .flatten()
                                .map(|h| h.command),
                        };
                        Msg::Fsm(Event::OutputCommandResolved { tool_id, command })
                    })
                    .detach();
                }
                Effect::ExecuteTool { tool_id, tool } => self.execute_tool(tool_id, tool, ctx),
                Effect::AbortTool { tool_id } => {
                    if let Some(interrupt_tx) = self.tool_interrupts.remove(&tool_id) {
                        let _ = interrupt_tx.send(());
                    }
                }
                Effect::CacheSessionGrant { path } => {
                    if let Some(io) = &mut self.io {
                        io.edit_permissions.grant(path);
                    }
                }
                Effect::WritePermissionRule {
                    target,
                    rule,
                    disposition,
                } => {
                    let Some(io) = &self.io else {
                        continue;
                    };
                    let file_path = match target {
                        PermissionTarget::Project => {
                            let project_root = io
                                .app_ctx
                                .git_root
                                .clone()
                                .or_else(|| std::env::current_dir().ok())
                                .unwrap_or_else(|| PathBuf::from("."));
                            crate::permissions::writer::project_permissions_path(&project_root)
                        }
                        PermissionTarget::Global => {
                            crate::permissions::writer::global_permissions_path()
                        }
                    };
                    tokio::spawn(async move {
                        if let Err(e) =
                            crate::permissions::writer::write_rule(&file_path, &rule, disposition)
                                .await
                        {
                            tracing::error!("Failed to write permission rule: {e}");
                        }
                    });
                }
                Effect::LoadSkill { name, arguments } => {
                    let Some(io) = &self.io else {
                        continue;
                    };
                    let registry = io.skill_registry.clone();
                    let shell = io.client_ctx.shell.clone().unwrap_or_else(|| "sh".to_string());
                    ctx.perform(async move {
                        let content = crate::tui::tools_exec::load_skill_content(
                            &registry,
                            &name,
                            &shell,
                            arguments.as_deref(),
                        )
                        .await;
                        Msg::Fsm(Event::SkillLoaded {
                            name,
                            arguments,
                            content,
                        })
                    })
                    .detach();
                }
                Effect::FetchModels => {
                    let Some(io) = &self.io else {
                        continue;
                    };
                    let endpoint = io.app_ctx.endpoint.clone();
                    let token = io.app_ctx.token.clone();
                    ctx.perform(async move {
                        let result = crate::models::fetch_models(&endpoint, &token)
                            .await
                            .map_err(|e| e.to_string());
                        Msg::Fsm(Event::ModelListLoaded(result))
                    })
                    .detach();
                }
                Effect::SaveModelSelection { alias } => {
                    let Some(_io) = &self.io else {
                        continue;
                    };
                    tokio::spawn(async move {
                        if let Err(e) = crate::models::save_model_selection(&alias).await {
                            tracing::error!("Failed to save model selection: {e}");
                        }
                    });
                }
            }
        }
        self.sync_permission_prompt();
        // A closed picker starts fresh next time.
        if self.fsm.ctx.model_picker.is_none() {
            self.model_select = SelectState::default();
        }
        self.push_ready_turns(ctx);
    }

    /// Keep the prompt cursor tied to the tool being asked about: a new
    /// question starts from the top.
    fn sync_permission_prompt(&mut self) {
        let current = self.fsm.ctx.tools.awaiting_permission().map(|t| t.id.clone());
        if current != self.permission_prompt_for {
            self.permission_prompt_for = current;
            self.permission_select = SelectState::default();
        }
    }

    /// Resolve a permission check: fast paths synchronously (yolo,
    /// auto-approved tools, cached per-session grants), the rules resolver
    /// as a spawned future. Without IO (headless), everything resolves to
    /// Ask — the same fallback the resolver uses on errors.
    fn check_permission(&mut self, tool_id: String, tool: ClientToolCall, ctx: &mut Ctx<'_, Self>) {
        let Some(io) = &self.io else {
            self.handle_fsm(
                Event::PermissionResolved {
                    tool_id,
                    response: PermissionResponse::Ask,
                },
                ctx,
            );
            return;
        };

        if io.app_ctx.yolo || tool.is_auto_approved() {
            self.handle_fsm(
                Event::PermissionResolved {
                    tool_id,
                    response: PermissionResponse::Allowed,
                },
                ctx,
            );
            return;
        }

        if let Some(resolved) = tool.resolved_file_path()
            && io.edit_permissions.has_valid_grant(&resolved)
        {
            self.handle_fsm(
                Event::PermissionResolved {
                    tool_id,
                    response: PermissionResponse::SessionGranted,
                },
                ctx,
            );
            return;
        }

        let working_dir = tool
            .target_dir()
            .map(|p| p.to_path_buf())
            .or_else(|| std::env::current_dir().ok())
            .unwrap_or_else(|| PathBuf::from("."));

        ctx.perform(async move {
            use crate::permissions::check::PermissionResponse as Check;
            let response =
                match crate::permissions::resolver::PermissionResolver::new(working_dir).await {
                    Ok(resolver) => match resolver.check(&tool) {
                        Check::Allowed => PermissionResponse::Allowed,
                        Check::Denied => PermissionResponse::Denied,
                        Check::Ask => PermissionResponse::Ask,
                    },
                    Err(_) => PermissionResponse::Ask,
                };
            Msg::Fsm(Event::PermissionResolved { tool_id, response })
        })
        .detach();
    }

    /// Execute an approved tool. Shell commands stream previews from a
    /// detached task (interrupt ≠ cancel: the outcome must still arrive);
    /// file tools are fast and run inline, as in v1; the rest are one-shot
    /// futures.
    fn execute_tool(&mut self, tool_id: String, tool: ClientToolCall, ctx: &mut Ctx<'_, Self>) {
        match tool {
            ClientToolCall::Shell(shell_call) => {
                let (interrupt_tx, interrupt_rx) = tokio::sync::oneshot::channel();
                self.tool_interrupts.insert(tool_id.clone(), interrupt_tx);
                ctx.spawn(crate::tui::tools_exec::shell_stream(tool_id, shell_call, interrupt_rx))
                    .detach();
            }
            ClientToolCall::Edit(edit_call) => {
                let resolved = edit_call.resolved_path();

                let old_content = std::fs::read(&resolved).ok();
                if let Some(content) = &old_content
                    && let Some(io) = &mut self.io
                    && let Some(store) = &mut io.snapshot_store
                    && let Err(e) = store.ensure_snapshot(&resolved, content)
                {
                    tracing::warn!("Failed to snapshot before edit: {e}");
                }

                let (outcome, new_content) = match &self.io {
                    Some(io) => edit_call.execute(&resolved, &io.file_tracker),
                    None => edit_call.execute(&resolved, &Default::default()),
                };

                if let Some(new_bytes) = &new_content
                    && let Some(io) = &mut self.io
                    && let Ok(mtime) = std::fs::metadata(&resolved).and_then(|m| m.modified())
                {
                    io.file_tracker.update_after_edit(&resolved, new_bytes, mtime);
                }

                let preview = match (&old_content, &new_content) {
                    (Some(old_bytes), Some(new_bytes)) => {
                        let old_str = String::from_utf8_lossy(old_bytes);
                        let new_str = String::from_utf8_lossy(new_bytes);
                        let diff = crate::diff::EditPreview::compute(&old_str, &new_str);
                        (!diff.hunks.is_empty()).then_some(ToolPreviewData::Edit(diff))
                    }
                    _ => None,
                };

                self.handle_fsm(
                    Event::ToolExecutionDone {
                        tool_id,
                        outcome,
                        preview,
                    },
                    ctx,
                );
            }
            ClientToolCall::Write(write_call) => {
                let resolved = write_call.resolved_path();

                if let Ok(content) = std::fs::read(&resolved)
                    && let Some(io) = &mut self.io
                    && let Some(store) = &mut io.snapshot_store
                    && let Err(e) = store.ensure_snapshot(&resolved, &content)
                {
                    tracing::warn!("Failed to snapshot before write: {e}");
                }

                let (outcome, written_bytes) = write_call.execute(&resolved);

                if let Some(new_bytes) = &written_bytes
                    && let Some(io) = &mut self.io
                    && let Ok(mtime) = std::fs::metadata(&resolved).and_then(|m| m.modified())
                {
                    io.file_tracker.update_after_edit(&resolved, new_bytes, mtime);
                }

                let preview = (!outcome.is_error()).then(|| {
                    ToolPreviewData::Write(crate::diff::WritePreview::from_content(
                        &write_call.content,
                    ))
                });

                self.handle_fsm(
                    Event::ToolExecutionDone {
                        tool_id,
                        outcome,
                        preview,
                    },
                    ctx,
                );
            }
            ClientToolCall::Read(read_call) => {
                let outcome = read_call.execute();

                if !outcome.is_error() {
                    let resolved = read_call.resolved_path();
                    if resolved.is_file()
                        && let Some(io) = &mut self.io
                        && let Ok(content) = std::fs::read(&resolved)
                        && let Ok(mtime) = std::fs::metadata(&resolved).and_then(|m| m.modified())
                    {
                        io.file_tracker.record_read(resolved, &content, mtime);
                    }
                }

                self.handle_fsm(
                    Event::ToolExecutionDone {
                        tool_id,
                        outcome,
                        preview: None,
                    },
                    ctx,
                );
            }
            ClientToolCall::AtuinHistory(history_call) => {
                let Some(io) = &self.io else {
                    return;
                };
                let db = io.app_ctx.history_db.clone();
                ctx.perform(async move {
                    let outcome = history_call.execute(&db).await;
                    Msg::Fsm(Event::ToolExecutionDone {
                        tool_id,
                        outcome,
                        preview: None,
                    })
                })
                .detach();
            }
            ClientToolCall::AtuinOutput(output_call) => {
                ctx.perform(async move {
                    let outcome = output_call.execute().await;
                    Msg::Fsm(Event::ToolExecutionDone {
                        tool_id,
                        outcome,
                        preview: None,
                    })
                })
                .detach();
            }
            ClientToolCall::LoadSkill(skill_call) => {
                let Some(io) = &self.io else {
                    return;
                };
                let registry = io.skill_registry.clone();
                let shell = io.client_ctx.shell.clone().unwrap_or_else(|| "sh".to_string());
                ctx.perform(async move {
                    let content = crate::tui::tools_exec::load_skill_content(
                        &registry,
                        &skill_call.name,
                        &shell,
                        None,
                    )
                    .await;
                    Msg::Fsm(Event::ToolExecutionDone {
                        tool_id,
                        outcome: crate::tools::ToolOutcome::Success(content),
                        preview: None,
                    })
                })
                .detach();
            }
        }
    }

    /// Launch the chat stream as a spawned message stream, replacing (and
    /// thereby cancelling) any previous one.
    fn start_stream(
        &mut self,
        messages: Vec<serde_json::Value>,
        session_id: Option<String>,
        ctx: &mut Ctx<'_, Self>,
    ) {
        let Some(io) = &self.io else {
            tracing::debug!("headless app: stream not started");
            return;
        };
        let (skill_summaries, skill_overflow) = io.skill_registry.server_skills();
        let request = crate::stream::ChatRequest::new(
            messages,
            session_id,
            &io.app_ctx.capabilities,
            io.app_ctx.daemon_enabled,
            self.fsm.ctx.invocation_id.clone(),
            self.fsm.ctx.model.clone(),
        );
        self.streaming = Some(ctx.spawn(crate::tui::bridge::stream_bridge(
            request,
            io.app_ctx.clone(),
            io.client_ctx.clone(),
            skill_summaries,
            skill_overflow,
            io.user_context_cache.clone(),
        )));
    }

    /// Queue a full-session snapshot on the persist worker. Jobs apply in
    /// channel order, so a later snapshot can never be overwritten by an
    /// earlier one.
    fn persist(&self) {
        let Some(io) = &self.io else {
            return;
        };
        let _ = io.persist.send(PersistJob::Session {
            events: self.fsm.ctx.events.clone(),
            server_session_id: self.fsm.ctx.session_id.clone(),
            file_tracker: io.file_tracker.to_json().ok(),
            edit_permissions: io.edit_permissions.to_json().ok(),
        });
    }

    /// Commit sealed turns to scrollback. A turn is sealed when nothing can
    /// mutate it anymore; an in-flight agent turn stays live.
    fn push_ready_turns(&mut self, ctx: &mut Ctx<'_, Self>) {
        // In Error state the failed turn stays live: Retry continues the
        // same turn, and pushing a partial turn would split it.
        if matches!(self.fsm.state, AgentState::Error(_)) {
            return;
        }
        let turns = self.live_turns();
        if turns.is_empty() {
            return;
        }
        if self.is_busy() && matches!(turns.last().map(|t| &t.kind), Some(UiTurnKind::Agent { .. }))
        {
            // Mid-stream: the whole in-flight agent turn stays in the tail
            // (statuses and text still mutate). It seals when the FSM
            // returns to Idle. Frontier-within-a-turn pushing is a later
            // optimization if tall turns ever hurt.
            return;
        }

        if self.pushed_turns == 0
            && let Some(notice) = self.resume_notice.take()
        {
            ctx.push(
                text(notice)
                    .style(Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC)),
            );
        }
        for (i, turn) in turns.iter().enumerate() {
            ctx.push(view::turn_view(turn, self.pushed_turns == 0 && i == 0, None));
            self.pushed_turns += 1;
        }
        self.pushed_events = self.fsm.ctx.events.len();
    }

    /// Translate submitted text into an FSM event: built-in slash commands,
    /// skills, or a plain user message.
    fn dispatch_submit(&self, input: String) -> Event {
        if input == "/new" {
            Event::NewSession
        } else if input == "/model" || input.starts_with("/model ") {
            Event::OpenModelPicker
        } else if input.starts_with('/') {
            if let Some((name, arguments)) = self.resolve_skill(&input) {
                Event::RequestSkillLoad { name, arguments }
            } else {
                let content = self.resolve_slash_command(&input);
                Event::SlashCommand {
                    command: input,
                    content,
                }
            }
        } else {
            Event::UserSubmit(input)
        }
    }

    /// Whether a slash invocation names a registered skill. Built-in slash
    /// commands take precedence: a skill can't shadow them.
    fn resolve_skill(&self, input: &str) -> Option<(String, Option<String>)> {
        let after_slash = input.trim_start_matches('/');
        let name = after_slash.split_whitespace().next()?.to_string();

        if !self.skill_names.contains(&name) || self.slash_registry.contains_builtin(&name) {
            return None;
        }

        let args = after_slash
            .strip_prefix(&name)
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .map(String::from);
        Some((name, args))
    }

    #[must_use]
    fn resolve_slash_command(&self, command: &str) -> String {
        match command.trim() {
            "/reload" => {
                if let Some(io) = &self.io {
                    io.user_context_cache.invalidate();
                }
                "Context files will be reloaded on the next request.".to_string()
            }
            "/help" => {
                let commands = self
                    .slash_registry
                    .get_commands()
                    .iter()
                    .map(|cmd| format!("- `/{}` — {}", cmd.name, cmd.description))
                    .collect::<Vec<_>>()
                    .join("\n");
                include_str!("content/help.md")
                    .replace("{commands}", &commands)
                    .replace("{docs_url}", &atuin_common::docs::url("ai/introduction/"))
            }
            other => format!("Unknown command: {other}"),
        }
    }

    /// Load an older (`back`) or newer submission from this session into
    /// the editor, shell-history style. Slash commands are recorded
    /// out-of-band with the typed invocation in `command`; skills carry
    /// their name and arguments, from which the invocation is rebuilt.
    fn recall_message(&mut self, back: bool) {
        let messages: Vec<String> = self
            .fsm
            .ctx
            .events
            .iter()
            .filter_map(|e| match e {
                ConversationEvent::UserMessage { content } => Some(content.clone()),
                ConversationEvent::OutOfBandOutput {
                    command: Some(cmd), ..
                } if cmd.starts_with('/') => Some(cmd.clone()),
                ConversationEvent::SkillInvocation {
                    name, arguments, ..
                } => Some(match arguments {
                    Some(args) => format!("/{name} {args}"),
                    None => format!("/{name}"),
                }),
                _ => None,
            })
            .collect();
        let text = if back {
            let current = self.input.get_mut().lines().join("\n");
            self.recall.back(&messages, &current)
        } else {
            self.recall.forward(&messages)
        };
        if let Some(text) = text {
            let editor = self.input.get_mut();
            editor.clear();
            editor.insert_str(text);
        }
    }

    /// Pull the next relevant tip for a starting turn.
    fn pull_tip(&mut self) -> Option<&'static Tip> {
        let has_context_files =
            self.io.as_ref().map(|io| io.user_context_cache.has_gathered()).unwrap_or(None);
        let tip_ctx = TipContext {
            settings: &self.settings,
            model_set: self.fsm.ctx.model.is_some(),
            has_context_files,
        };
        self.tips.next(&tip_ctx)
    }

    fn submit(&mut self, ctx: &mut Ctx<'_, Self>) {
        let input = {
            let editor = self.input.get_mut();
            let text = editor.lines().join("\n").trim().to_string();
            if text.is_empty() {
                return;
            }
            editor.clear();
            text
        };
        self.recall.reset();
        self.slash_results.clear();
        let event = self.dispatch_submit(input);
        self.handle_fsm(event, ctx);
    }
}

impl App for AiApp {
    type Msg = Msg;
    type Output = ExitOutcome;

    fn init(&mut self, ctx: &mut Ctx<'_, Self>) {
        if self.usage_stale
            && let Some(io) = &self.io
        {
            let endpoint = io.app_ctx.endpoint.clone();
            let token = io.app_ctx.token.clone();
            ctx.perform(async move {
                match crate::usage::fetch_usage(&endpoint, &token).await {
                    Ok(snapshot) => Msg::Usage(snapshot),
                    Err(e) => Msg::UsageFetchFailed(e.to_string()),
                }
            })
            .detach();
        }

        if let Some(prompt) = self.initial_prompt.take() {
            let event = self.dispatch_submit(prompt.trim().to_string());
            self.handle_fsm(event, ctx);
        }
    }

    fn update(&mut self, msg: Msg, ctx: &mut Ctx<'_, Self>) {
        match msg {
            Msg::Input(event) => {
                if !self.input_active() {
                    return;
                }
                match event {
                    InputEvent::Key(k) => {
                        let editor = self.input.get_mut();
                        let cursor = editor.cursor();
                        editor.input(k);
                        // A plain Up/Down whose cursor had nowhere to go
                        // (top/bottom visual row) recalls session messages
                        // instead. The kind guard keeps kitty-protocol
                        // release events — no-ops in the editor — from
                        // double-stepping.
                        if editor.cursor() == cursor
                            && matches!(k.code, KeyCode::Up | KeyCode::Down)
                            && k.modifiers.is_empty()
                            && k.kind != KeyEventKind::Release
                        {
                            self.recall_message(k.code == KeyCode::Up);
                        }
                    }
                    InputEvent::Paste(s) => {
                        self.input.get_mut().insert_str(s);
                    }
                }
                self.refresh_slash();
            }
            Msg::Newline => {
                if self.input_active() {
                    self.input.get_mut().insert_newline();
                }
            }
            Msg::AcceptSlashSuggestion => {
                if let Some(first) = self.slash_results.first() {
                    let name = first.command.name.clone();
                    let editor = self.input.get_mut();
                    editor.clear();
                    editor.insert_str(format!("/{name}"));
                    self.refresh_slash();
                }
            }
            Msg::Submit => self.submit(ctx),
            Msg::PermissionSelect(sel) => {
                let len = self
                    .fsm
                    .ctx
                    .tools
                    .awaiting_permission()
                    .map(|t| view::permission_options(&t.tool, self.in_git_project).len())
                    .unwrap_or(0);
                self.permission_select.handle(sel, len);
            }
            Msg::ConfirmPermission => {
                let Some(tool) = self.fsm.ctx.tools.awaiting_permission() else {
                    return;
                };
                let tool_id = tool.id.clone();
                let options = view::permission_options(&tool.tool, self.in_git_project);
                let Some((_, result)) = options.get(self.permission_select.cursor) else {
                    return;
                };
                let choice = match result {
                    PermissionResult::Allow => PermissionChoice::Allow,
                    PermissionResult::AllowFileForSession => PermissionChoice::AllowForSession,
                    PermissionResult::AlwaysAllowInDir => PermissionChoice::AlwaysAllowInProject,
                    PermissionResult::AlwaysAllow => PermissionChoice::AlwaysAllow,
                    PermissionResult::Deny => PermissionChoice::Deny,
                };
                self.handle_fsm(Event::PermissionUserChoice { tool_id, choice }, ctx);
            }
            Msg::ExecuteCommand => self.handle_fsm(Event::ExecuteCommand, ctx),
            Msg::InsertCommand => self.handle_fsm(Event::InsertCommand, ctx),
            Msg::Cancel => self.handle_fsm(Event::Cancel, ctx),
            Msg::Interrupt => self.handle_fsm(Event::InterruptTools, ctx),
            Msg::Retry => self.handle_fsm(Event::Retry, ctx),
            Msg::Quit(outcome) => {
                self.exiting = true;
                ctx.exit(outcome);
            }
            Msg::ModelSelect(sel) => {
                let len = match &self.fsm.ctx.model_picker {
                    Some(crate::fsm::ModelPicker::Ready(list)) => list.models.len(),
                    _ => 0,
                };
                self.model_select.handle(sel, len);
            }
            Msg::ConfirmModel => {
                let Some(crate::fsm::ModelPicker::Ready(list)) = &self.fsm.ctx.model_picker else {
                    return;
                };
                let Some(model) = list.models.get(self.model_select.cursor) else {
                    return;
                };
                let alias = model.alias.clone();
                self.handle_fsm(Event::ModelSelected(alias), ctx);
            }
            Msg::Fsm(event) => self.handle_fsm(event, ctx),
            Msg::UsageFetchFailed(e) => tracing::debug!("background usage fetch failed: {e}"),
            Msg::Usage(snapshot) => {
                if let Some(io) = &self.io {
                    let _ = io.persist.send(PersistJob::Usage {
                        key: crate::usage::cache_key(&io.app_ctx.token),
                        snapshot: snapshot.clone(),
                    });
                }
                self.usage = Some(snapshot);
            }
        }
    }

    fn tail(&self) -> impl Element + '_ {
        let turns = self.live_turns();
        let busy = self.is_busy();
        let last = turns.len().saturating_sub(1);
        let asking = self.fsm.ctx.tools.awaiting_permission();
        let ready_picker = match &self.fsm.ctx.model_picker {
            Some(crate::fsm::ModelPicker::Ready(list)) if asking.is_none() => Some(list),
            _ => None,
        };
        let picker_loading =
            matches!(self.fsm.ctx.model_picker, Some(crate::fsm::ModelPicker::Loading));
        let show_input = asking.is_none() && ready_picker.is_none() && !self.exiting;
        let needs_pending_banner =
            busy && !matches!(turns.last().map(|t| &t.kind), Some(UiTurnKind::Agent { .. }));

        let status_text = if let AgentState::Turn {
            stream: StreamPhase::Streaming {
                status: Some(status),
            },
        } = &self.fsm.state
        {
            Some(capitalize(status.to_str()))
        } else {
            None
        };

        col()
            .when_some(
                (self.pushed_turns == 0).then_some(self.resume_notice.clone()).flatten(),
                |c, notice| {
                    c.child(
                        text(notice).style(
                            Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC),
                        ),
                    )
                },
            )
            .children(turns.iter().enumerate().map(|(i, turn)| {
                let working = (busy && i == last && asking.is_none()).then_some(view::Working {
                    status: status_text.as_deref(),
                    tip: self.turn_tip,
                });

                view::turn_view(turn, self.pushed_turns == 0 && i == 0, working)
            }))
            .when(needs_pending_banner, |c| {
                let working = asking.is_none().then_some(view::Working {
                    status: status_text.as_deref(),
                    tip: self.turn_tip,
                });
                c.child(view::agent_turn_view(&[], working))
            })
            .when_some(self.responded.as_ref(), |c, responded| {
                c.child(view::responded_view(responded.elapsed, responded.tip))
            })
            .when_some(
                match &self.fsm.state {
                    AgentState::Error(msg) => Some(msg.clone()),
                    _ => None,
                },
                |c, msg| {
                    c.child(
                        text("Error: ")
                            .style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD))
                            .span(msg, Style::default().fg(Color::Red))
                            .pad_left(2)
                            .pad_top(1),
                    )
                },
            )
            .when_some(asking, |c, tool| {
                c.child(view::permission_prompt_view(
                    tool,
                    self.in_git_project,
                    self.permission_select.cursor,
                ))
            })
            .when_some(ready_picker, |c, list| {
                c.child(view::model_picker_view(
                    list,
                    self.fsm.ctx.model.as_deref(),
                    self.model_select.cursor,
                ))
            })
            .when(picker_loading, |c| {
                c.child(
                    view::tool_spinner("Loading models…", false)
                        .label_style(Style::default().fg(Color::Gray))
                        .pad_top(1),
                )
            })
            .when(show_input, |c| {
                c.child(view::input::input_area(
                    &self.input,
                    self.input_active(),
                    self.footer_text(),
                    self.is_input_blank() && self.has_command() && self.input_active(),
                    &self.slash_results,
                ))
                .child(view::status_bar_view(self.fsm.ctx.model.as_deref(), self.usage.as_ref()))
            })
    }

    fn keymap(&self) -> Keymap<Msg> {
        let mut km = keymap();

        // Ctrl+C: interrupt a running tool execution, else exit.
        km = km.on_override(
            key(KeyCode::Char('c')).ctrl(),
            if self.shell_executing() {
                Msg::Interrupt
            } else {
                Msg::Quit(ExitOutcome::Cancel)
            },
        );

        // Esc: interrupt a live tool preview; otherwise Cancel — the FSM
        // resolves what cancelling means in every state (close the picker,
        // clear a confirmation, abort the stream, or exit).
        km = km.on(
            key(KeyCode::Esc),
            if self.shell_executing() {
                Msg::Interrupt
            } else {
                Msg::Cancel
            },
        );

        if matches!(self.fsm.state, AgentState::Error(_)) {
            km = km.on(key(KeyCode::Enter), Msg::Retry).on(key(KeyCode::Char('r')), Msg::Retry);
        }

        if self.has_confirmation() {
            km = km.on(key(KeyCode::Enter), Msg::ExecuteCommand);
        }

        if self.fsm.ctx.tools.awaiting_permission().is_some() {
            km = km
                .on(key(KeyCode::Enter), Msg::ConfirmPermission)
                .merge(SelectState::keymap().map(Msg::PermissionSelect));
        } else if matches!(self.fsm.ctx.model_picker, Some(crate::fsm::ModelPicker::Ready(_))) {
            km = km
                .on(key(KeyCode::Enter), Msg::ConfirmModel)
                .merge(SelectState::keymap().map(Msg::ModelSelect));
        }

        let picker_open = self.fsm.ctx.model_picker.is_some();
        if self.input_active() && !picker_open {
            if !self.slash_results.is_empty() {
                km = km.on(key(KeyCode::Tab), Msg::AcceptSlashSuggestion);
            }
            if self.is_input_blank() && self.has_command() {
                km = km
                    .on(key(KeyCode::Enter), Msg::ExecuteCommand)
                    .on(key(KeyCode::Tab), Msg::InsertCommand);
            } else {
                km = km.on(key(KeyCode::Enter), Msg::Submit);
            }
            km = km
                .on(key(KeyCode::Enter).shift(), Msg::Newline)
                .on(key(KeyCode::Char('j')).ctrl(), Msg::Newline)
                .fallthrough(&self.input_focus, Msg::Input);
        }

        km
    }
}

fn capitalize(s: &str) -> String {
    let mut c = s.chars();
    match c.next() {
        None => String::new(),
        Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
    }
}

#[cfg(test)]
mod tests {
    use crossterm::event::{KeyEvent, KeyModifiers};
    use eye_declare::Runtime;
    use eye_declare_engine::test_terminal::TestTerminal;
    use rstest::{fixture, rstest};

    use super::*;
    use crate::tui::state::ConversationEvent;

    fn fixture_app() -> AiApp {
        let mut fsm = AgentFsm::new(vec![], "test-invocation".into());
        fsm.ctx.events.push(ConversationEvent::UserMessage {
            content: "list my files".into(),
        });
        fsm.ctx.events.push(ConversationEvent::Text {
            content: "Use `ls` to list files.".into(),
        });
        app_with(fsm)
    }

    fn app_with(fsm: AgentFsm) -> AiApp {
        AiApp::headless(fsm, None, SlashCommandRegistry::default(), HashSet::new())
    }

    /// Runtime + VTE terminal pair. Every byte the runtime emits — including
    /// per-event output from `handle` — must reach the terminal, or its
    /// state diverges from what the diffing runtime believes is on screen.
    struct Harness {
        rt: Runtime<AiApp>,
        term: TestTerminal,
    }

    impl Harness {
        fn new(app: AiApp) -> Self {
            let mut rt = Runtime::new(app, 60, 24);
            let mut term = TestTerminal::new(60, 24);
            term.feed(&rt.present());
            Self { rt, term }
        }

        fn press(&mut self, code: KeyCode) -> Option<ExitOutcome> {
            self.press_mod(code, KeyModifiers::NONE)
        }

        fn press_mod(&mut self, code: KeyCode, modifiers: KeyModifiers) -> Option<ExitOutcome> {
            let (bytes, exit) = self.rt.handle(InputEvent::Key(KeyEvent::new(code, modifiers)));
            self.term.feed(&bytes);
            exit
        }

        fn type_str(&mut self, s: &str) {
            for ch in s.chars() {
                self.press(KeyCode::Char(ch));
            }
        }

        /// Deliver a message as spawned work would (e.g. stream frames).
        fn process(&mut self, msg: Msg) -> Option<ExitOutcome> {
            let (bytes, exit) = self.rt.process(msg);
            self.term.feed(&bytes);
            exit
        }

        fn stream(&mut self, event: Event) {
            self.process(Msg::Fsm(event));
        }

        fn app(&self) -> &AiApp {
            self.rt.app()
        }

        fn screen(&self) -> String {
            self.term.viewport_lines().join("\n")
        }

        /// Scrollback + viewport, for content that has scrolled off.
        fn all_lines(&self) -> String {
            [self.term.scrollback_lines(), self.term.viewport_lines()].concat().join("\n")
        }
    }

    #[fixture]
    fn harness() -> Harness {
        Harness::new(fixture_app())
    }

    fn suggest_command_fsm(cmd: &str) -> AgentFsm {
        let mut fsm = AgentFsm::new(vec![], "test-invocation".into());
        fsm.ctx.events.push(ConversationEvent::ToolCall {
            id: "t1".into(),
            name: "suggest_command".into(),
            input: serde_json::json!({ "command": cmd }),
        });
        fsm
    }

    #[rstest]
    fn tail_renders_conversation_turns(#[from(harness)] h: Harness) {
        let screen = h.screen();
        assert!(screen.contains(" You"), "user label missing:\n{screen}");
        assert!(screen.contains("list my files"), "user text missing:\n{screen}");
        assert!(screen.contains(" Atuin AI"), "agent label missing:\n{screen}");
        assert!(screen.contains("Use ls to list files."), "agent markdown missing:\n{screen}");
    }

    #[rstest]
    fn esc_exits_with_cancel(#[from(harness)] mut h: Harness) {
        assert_eq!(h.press(KeyCode::Esc), Some(ExitOutcome::Cancel));
    }

    #[rstest]
    fn exit_erases_the_input_box(#[from(harness)] mut h: Harness) {
        assert!(
            h.screen().contains("Generate a command or ask a question"),
            "input box missing before exit:\n{}",
            h.screen()
        );

        h.press(KeyCode::Esc);
        assert!(
            !h.all_lines().contains("Generate a command or ask a question"),
            "input box still on screen after exit:\n{}",
            h.all_lines()
        );
    }

    #[rstest]
    fn typing_renders_in_the_editor(#[from(harness)] mut h: Harness) {
        h.type_str("hi there");

        let screen = h.screen();
        assert!(screen.contains("hi there"), "typed text missing:\n{screen}");
        assert!(
            screen.contains("Generate a command or ask a question"),
            "panel title missing:\n{screen}"
        );
    }

    #[rstest]
    fn enter_submits_and_pushes_the_user_turn(#[from(harness)] mut h: Harness) {
        h.type_str("hello agent");
        let exit = h.press(KeyCode::Enter);
        assert_eq!(exit, None);

        let app = h.app();
        assert!(matches!(
            app.fsm.ctx.events.last(),
            Some(ConversationEvent::UserMessage { content }) if content == "hello agent"
        ));
        assert!(app.is_input_blank(), "editor should clear on submit");
        assert!(app.is_busy(), "fsm should be in a turn");
        assert!(app.pushed_turns > 0, "turns should have been pushed");

        // The submitted turn is now committed content; the tail shows the
        // pending-agent banner.
        let all = h.all_lines();
        assert!(all.contains("hello agent"), "pushed turn missing:\n{all}");
        assert!(all.contains(" Atuin AI"), "pending banner missing:\n{all}");
    }

    #[rstest]
    #[case::blank_enter_executes(KeyCode::Enter, ExitOutcome::Execute("ls -la".into()))]
    #[case::tab_inserts(KeyCode::Tab, ExitOutcome::Insert("ls -la".into()))]
    fn suggested_command_key(#[case] key: KeyCode, #[case] expected: ExitOutcome) {
        let mut h = Harness::new(app_with(suggest_command_fsm("ls -la")));
        assert_eq!(h.press(key), Some(expected));
    }

    #[rstest]
    fn typing_disarms_command_execution() {
        let mut h = Harness::new(app_with(suggest_command_fsm("rm -rf /")));
        h.type_str("actually, explain first");
        // Enter now submits the question instead of executing the command.
        assert_eq!(h.press(KeyCode::Enter), None);
        assert!(matches!(
            h.app().fsm.ctx.events.last(),
            Some(ConversationEvent::UserMessage { .. })
        ));
    }

    #[rstest]
    fn slash_suggestions_and_tab_accept(#[from(harness)] mut h: Harness) {
        h.type_str("/he");

        let screen = h.screen();
        assert!(screen.contains("Show help information"), "suggestion missing:\n{screen}");

        h.press(KeyCode::Tab);
        assert_eq!(h.app().input.borrow().lines(), ["/help".to_string()]);
    }

    #[rstest]
    fn ctrl_j_inserts_newline(#[from(harness)] mut h: Harness) {
        h.type_str("a");
        h.press_mod(KeyCode::Char('j'), KeyModifiers::CONTROL);
        h.type_str("b");
        assert_eq!(h.app().input.borrow().lines(), ["a".to_string(), "b".to_string()]);
    }

    #[rstest]
    fn shift_enter_inserts_newline(#[from(harness)] mut h: Harness) {
        h.type_str("a");
        h.press_mod(KeyCode::Enter, KeyModifiers::SHIFT);
        h.type_str("b");
        assert_eq!(h.app().input.borrow().lines().len(), 2);
    }

    fn two_message_fsm() -> AgentFsm {
        let mut fsm = AgentFsm::new(vec![], "test-invocation".into());
        for (q, a) in [("first question", "one"), ("second question", "two")] {
            fsm.ctx.events.push(ConversationEvent::UserMessage { content: q.into() });
            fsm.ctx.events.push(ConversationEvent::Text { content: a.into() });
        }
        fsm
    }

    #[rstest]
    fn up_recalls_previous_messages_and_down_restores_the_draft() {
        let mut h = Harness::new(app_with(two_message_fsm()));
        h.type_str("draft");

        h.press(KeyCode::Up);
        assert_eq!(h.app().input.borrow().lines(), ["second question"]);
        h.press(KeyCode::Up);
        assert_eq!(h.app().input.borrow().lines(), ["first question"]);
        // Nothing older: stays put.
        h.press(KeyCode::Up);
        assert_eq!(h.app().input.borrow().lines(), ["first question"]);

        h.press(KeyCode::Down);
        assert_eq!(h.app().input.borrow().lines(), ["second question"]);
        h.press(KeyCode::Down);
        assert_eq!(h.app().input.borrow().lines(), ["draft"]);
        // Out of recall: Down is a no-op again.
        h.press(KeyCode::Down);
        assert_eq!(h.app().input.borrow().lines(), ["draft"]);
    }

    #[rstest]
    fn up_moves_the_cursor_before_it_recalls() {
        let mut h = Harness::new(app_with(two_message_fsm()));
        h.type_str("a");
        h.press_mod(KeyCode::Char('j'), KeyModifiers::CONTROL);
        h.type_str("b");

        // Cursor on the second line: Up moves it, no recall.
        h.press(KeyCode::Up);
        assert_eq!(h.app().input.borrow().lines(), ["a", "b"]);
        // At the top row: Up recalls.
        h.press(KeyCode::Up);
        assert_eq!(h.app().input.borrow().lines(), ["second question"]);
        // The multi-line draft survives the round trip.
        h.press(KeyCode::Down);
        assert_eq!(h.app().input.borrow().lines(), ["a", "b"]);
    }

    #[rstest]
    fn recall_includes_slash_commands_and_skills() {
        let mut h = Harness::new(app_with(two_message_fsm()));
        h.type_str("/help");
        h.press(KeyCode::Enter);
        // Loading a skill starts a turn; finish it to return to Idle.
        h.process(Msg::Fsm(Event::SkillLoaded {
            name: "review".into(),
            arguments: Some("main".into()),
            content: "skill content".into(),
        }));
        h.stream(Event::StreamStarted);
        h.stream(Event::StreamDone {
            session_id: "s1".into(),
        });

        h.press(KeyCode::Up);
        assert_eq!(h.app().input.borrow().lines(), ["/review main"]);
        h.press(KeyCode::Up);
        assert_eq!(h.app().input.borrow().lines(), ["/help"]);
        h.press(KeyCode::Up);
        assert_eq!(h.app().input.borrow().lines(), ["second question"]);
    }

    #[rstest]
    fn recall_does_nothing_without_user_messages() {
        let mut h = Harness::new(app_with(AgentFsm::new(vec![], "t".into())));
        h.type_str("draft");
        h.press(KeyCode::Up);
        assert_eq!(h.app().input.borrow().lines(), ["draft"]);
    }

    #[rstest]
    fn submit_resets_recall_to_the_newest_message() {
        let mut h = Harness::new(app_with(two_message_fsm()));
        h.press(KeyCode::Up);
        h.press(KeyCode::Up); // recalled "first question"
        h.press(KeyCode::Enter); // submit it
        h.stream(Event::StreamStarted);
        h.stream(Event::StreamDone {
            session_id: "s1".into(),
        });

        // Recall starts over from the newest message — the one just sent.
        h.press(KeyCode::Up);
        assert_eq!(h.app().input.borrow().lines(), ["first question"]);
        h.press(KeyCode::Up);
        assert_eq!(h.app().input.borrow().lines(), ["second question"]);
    }

    #[rstest]
    fn streaming_text_renders_live_and_seals_on_done() {
        let mut h = Harness::new(app_with(AgentFsm::new(vec![], "t".into())));
        h.type_str("what is atuin?");
        h.press(KeyCode::Enter);

        h.stream(Event::StreamStarted);
        h.stream(Event::StreamChunk("Atuin is a ".into()));
        h.stream(Event::StreamChunk("shell history tool.".into()));

        // Mid-stream: text is visible in the live tail, nothing new pushed.
        let pushed_mid = h.app().pushed_turns;
        assert!(h.app().is_busy());
        assert!(
            h.screen().contains("Atuin is a shell history tool."),
            "streaming text missing:\n{}",
            h.screen()
        );

        h.stream(Event::StreamDone {
            session_id: "s1".into(),
        });

        let app = h.app();
        assert!(!app.is_busy(), "turn should be over");
        assert!(app.pushed_turns > pushed_mid, "agent turn should seal to scrollback on Done");
        assert_eq!(app.pushed_events, app.fsm.ctx.events.len(), "frontier should cover all events");
        // The sealed turn is on the terminal, and the live tail no longer
        // repeats it.
        let all = h.all_lines();
        assert!(all.contains("Atuin is a shell history tool."));
        assert!(!h.app().fsm.ctx.current_response.contains("Atuin"));
    }

    #[rstest]
    fn tip_hangs_off_the_spinner_while_streaming() {
        let mut h = Harness::new(app_with(AgentFsm::new(vec![], "t".into())));
        h.type_str("hello");
        h.press(KeyCode::Enter);
        h.stream(Event::StreamStarted);

        let screen = h.screen();
        // Headless apps start the rotation at tip 0.
        assert!(
            screen.contains("└ Tip: press Esc to interrupt a response"),
            "tip dropper missing under spinner:\n{screen}"
        );
    }

    #[rstest]
    fn responded_line_replaces_the_spinner_on_done() {
        let mut h = Harness::new(app_with(AgentFsm::new(vec![], "t".into())));
        h.type_str("hello");
        h.press(KeyCode::Enter);
        h.stream(Event::StreamStarted);
        h.stream(Event::StreamChunk("Hi!".into()));
        h.stream(Event::StreamDone {
            session_id: "s1".into(),
        });

        let screen = h.screen();
        assert!(screen.contains("Responded in"), "responded line missing:\n{screen}");
        assert!(
            screen.contains("└ Tip: press Esc to interrupt a response"),
            "turn's tip should ride along onto the responded line:\n{screen}"
        );

        // The next submit clears it and rotates to a fresh tip.
        h.type_str("more");
        h.press(KeyCode::Enter);
        h.stream(Event::StreamStarted);
        let screen = h.screen();
        assert!(
            !screen.contains("Responded in"),
            "responded line should clear on new turn:\n{screen}"
        );
        assert!(screen.contains("└ Tip:"), "second turn should show a tip\n{screen}");
        assert!(
            !screen.contains("└ Tip: press Esc to interrupt a response"),
            "second turn should pull the next tip:\n{screen}"
        );
    }

    #[rstest]
    fn cancelled_turn_shows_no_responded_line() {
        let mut h = Harness::new(app_with(AgentFsm::new(vec![], "t".into())));
        h.type_str("hello");
        h.press(KeyCode::Enter);
        h.stream(Event::StreamStarted);
        h.stream(Event::StreamChunk("partial".into()));
        h.press(KeyCode::Esc);

        assert!(!h.app().is_busy());
        assert!(
            !h.all_lines().contains("Responded in"),
            "cancel is not a clean completion:\n{}",
            h.all_lines()
        );
    }

    #[rstest]
    fn tips_disabled_in_settings_suppresses_the_tip_line() {
        let mut app = app_with(AgentFsm::new(vec![], "t".into()));
        app.settings.ai.tips = Some(false);
        let mut h = Harness::new(app);
        h.type_str("hello");
        h.press(KeyCode::Enter);
        h.stream(Event::StreamStarted);
        h.stream(Event::StreamDone {
            session_id: "s1".into(),
        });

        let all = h.all_lines();
        assert!(!all.contains("Tip:"), "tips disabled but shown:\n{all}");
        assert!(all.contains("Responded in"), "responded line should still show:\n{all}");
    }

    #[rstest]
    fn esc_mid_stream_cancels_generation() {
        let mut h = Harness::new(app_with(AgentFsm::new(vec![], "t".into())));
        h.type_str("hello");
        h.press(KeyCode::Enter);
        h.stream(Event::StreamStarted);
        h.stream(Event::StreamChunk("partial".into()));

        assert_eq!(h.press(KeyCode::Esc), None, "Esc cancels, not exits");
        assert!(!h.app().is_busy(), "cancel should end the turn");
        // A second Esc from Idle exits.
        assert_eq!(h.press(KeyCode::Esc), Some(ExitOutcome::Cancel));
    }

    #[rstest]
    fn stream_error_offers_retry() {
        let mut h = Harness::new(app_with(AgentFsm::new(vec![], "t".into())));
        h.type_str("hello");
        h.press(KeyCode::Enter);
        h.stream(Event::StreamStarted);
        let pushed_before = h.app().pushed_turns;
        h.stream(Event::StreamError("connection lost".into()));

        assert!(matches!(h.app().fsm.state, AgentState::Error(_)));
        assert_eq!(h.app().pushed_turns, pushed_before, "failed turn must stay live for retry");
        let screen = h.screen();
        assert!(screen.contains("Error: connection lost"), "error line missing:\n{screen}");
        assert!(screen.contains("[Enter]/[r] Retry"), "retry footer:\n{screen}");

        h.press(KeyCode::Char('r'));
        assert!(h.app().is_busy(), "retry should restart the turn");
    }

    #[rstest]
    fn usage_snapshot_is_stored() {
        let mut h = Harness::new(app_with(AgentFsm::new(vec![], "t".into())));
        let bucket = crate::usage::UsageBucket { used: 1, limit: 10 };
        let snapshot = crate::usage::UsageSnapshot {
            period: "calendar_monthly".into(),
            resets_at: "2026-08-01T00:00:00Z".into(),
            requests: bucket.clone(),
            input: bucket.clone(),
            output: bucket,
        };
        h.process(Msg::Usage(snapshot.clone()));
        assert_eq!(h.app().usage, Some(snapshot));
    }

    /// Drive a turn to the point where a shell tool awaits permission.
    /// Headless permission checks resolve to Ask (no rules to consult),
    /// which is exactly the prompt path.
    #[fixture]
    fn harness_awaiting_shell_permission() -> Harness {
        let mut h = Harness::new(app_with(AgentFsm::new(
            vec!["client_v1_execute_shell_command".into()],
            "t".into(),
        )));
        h.type_str("run something");
        h.press(KeyCode::Enter);
        h.stream(Event::StreamStarted);
        h.stream(Event::StreamToolCall {
            id: "tool-1".into(),
            name: "execute_shell_command".into(),
            input: serde_json::json!({ "command": "echo hi" }),
        });
        h.stream(Event::StreamDone {
            session_id: "s1".into(),
        });
        assert!(
            h.app().fsm.ctx.tools.awaiting_permission().is_some(),
            "fixture should end awaiting permission"
        );
        h
    }

    #[rstest]
    fn permission_prompt_renders_and_replaces_input(
        #[from(harness_awaiting_shell_permission)] h: Harness,
    ) {
        let screen = h.screen();
        assert!(screen.contains("Atuin AI would like to run: "), "prompt line missing:\n{screen}");
        assert!(screen.contains("echo hi"), "tool desc missing:\n{screen}");
        assert!(screen.contains("Allow"), "options missing:\n{screen}");
        assert!(
            !screen.contains("Generate a command or ask a question"),
            "input panel should be hidden while asking:\n{screen}"
        );
    }

    #[rstest]
    fn permission_cursor_moves_and_wraps(
        #[from(harness_awaiting_shell_permission)] mut h: Harness,
    ) {
        assert_eq!(h.app().permission_select.cursor, 0);
        h.press(KeyCode::Down);
        assert_eq!(h.app().permission_select.cursor, 1);
        h.press(KeyCode::Up);
        h.press(KeyCode::Up);
        // 4 options: wraps to the last (Deny).
        assert_eq!(h.app().permission_select.cursor, 3);
    }

    #[rstest]
    fn allowing_starts_execution_with_an_interrupt_handle(
        #[from(harness_awaiting_shell_permission)] mut h: Harness,
    ) {
        h.press(KeyCode::Enter); // cursor 0 = Allow

        let app = h.app();
        assert!(app.fsm.ctx.tools.awaiting_permission().is_none());
        assert!(
            app.tool_interrupts.contains_key("tool-1"),
            "shell execution should register an interrupt sender"
        );
    }

    #[rstest]
    fn denying_resolves_the_tool_without_executing(
        #[from(harness_awaiting_shell_permission)] mut h: Harness,
    ) {
        h.press(KeyCode::Up); // wrap to Deny
        assert_eq!(h.app().permission_select.cursor, 3);
        h.press(KeyCode::Enter);

        let app = h.app();
        assert!(app.fsm.ctx.tools.awaiting_permission().is_none());
        assert!(app.tool_interrupts.is_empty(), "denied tool must not execute");
        assert!(
            app.fsm
                .ctx
                .events
                .iter()
                .any(|e| matches!(e, ConversationEvent::ToolResult { is_error: true, .. })),
            "denial should record an error tool result"
        );
    }

    #[rstest]
    fn ctrl_c_interrupts_from_the_moment_a_shell_spawns(
        #[from(harness_awaiting_shell_permission)] mut h: Harness,
    ) {
        h.press(KeyCode::Enter); // Allow → executing
        assert!(h.app().tool_interrupts.contains_key("tool-1"));

        // No preview output yet — the command just spawned. Ctrl+C must
        // interrupt it, not exit the TUI on top of a running command.
        let exit = h.press_mod(KeyCode::Char('c'), KeyModifiers::CONTROL);
        assert_eq!(exit, None, "must not quit while a command runs");
        assert!(
            h.app().tool_interrupts.is_empty(),
            "Ctrl+C should fire and drop the interrupt sender"
        );
    }

    #[rstest]
    fn finished_shell_releases_ctrl_c_back_to_quit(
        #[from(harness_awaiting_shell_permission)] mut h: Harness,
    ) {
        h.press(KeyCode::Enter); // Allow → executing
        h.stream(Event::ToolExecutionDone {
            tool_id: "tool-1".into(),
            outcome: crate::tools::ToolOutcome::Success("done".into()),
            preview: None,
        });
        assert!(h.app().tool_interrupts.is_empty(), "completion must prune the interrupt sender");
        // With nothing executing, Ctrl+C means quit again.
        assert_eq!(
            h.press_mod(KeyCode::Char('c'), KeyModifiers::CONTROL),
            Some(ExitOutcome::Cancel)
        );
    }

    #[rstest]
    fn allowed_read_tool_executes_inline() {
        use std::io::Write as _;
        let mut file = tempfile::NamedTempFile::new().unwrap();
        writeln!(file, "hello from the file").unwrap();

        let mut h =
            Harness::new(app_with(AgentFsm::new(vec!["client_v1_read_file".into()], "t".into())));
        h.type_str("read it");
        h.press(KeyCode::Enter);
        h.stream(Event::StreamStarted);
        h.stream(Event::StreamToolCall {
            id: "tool-r".into(),
            name: "read_file".into(),
            input: serde_json::json!({ "file_path": file.path().to_str().unwrap() }),
        });
        h.stream(Event::StreamDone {
            session_id: "s1".into(),
        });
        h.press(KeyCode::Enter); // Allow

        let app = h.app();
        assert!(
            app.fsm.ctx.events.iter().any(|e| matches!(
                e,
                ConversationEvent::ToolResult { content, is_error: false, .. }
                    if content.contains("hello from the file")
            )),
            "read result should carry the file content"
        );
    }

    #[rstest]
    fn init_submits_the_initial_prompt_and_pushes_the_turn() {
        let mut app = app_with(AgentFsm::new(vec![], "t".into()));
        app.initial_prompt = Some("what is atuin?".into());

        let mut rt = Runtime::new(app, 60, 24);
        let mut term = TestTerminal::new(60, 24);
        let (bytes, exit) = rt.startup();
        assert!(exit.is_none());
        term.feed(&bytes);

        assert!(rt.app().is_busy(), "initial prompt should start a turn");
        assert!(rt.app().pushed_turns > 0, "user turn should be pushed");
        let all = [term.scrollback_lines(), term.viewport_lines()].concat().join("\n");
        assert!(all.contains("what is atuin?"), "prompt missing:\n{all}");
    }

    fn model_list() -> crate::models::ModelList {
        crate::models::ModelList {
            default: "smart".into(),
            models: vec![
                crate::models::ModelInfo {
                    alias: "smart".into(),
                    name: "Smart".into(),
                    description: "balanced".into(),
                },
                crate::models::ModelInfo {
                    alias: "fast".into(),
                    name: "Fast".into(),
                    description: "quick answers".into(),
                },
            ],
        }
    }

    #[rstest]
    fn model_picker_flow_selects_a_model() {
        let mut h = Harness::new(app_with(AgentFsm::new(vec![], "t".into())));
        h.type_str("/model");
        h.press(KeyCode::Enter);

        // Headless: FetchModels degrades, picker sits in Loading.
        assert!(h.screen().contains("Loading models…"), "loading spinner missing:\n{}", h.screen());

        h.stream(Event::ModelListLoaded(Ok(model_list())));
        let screen = h.screen();
        assert!(screen.contains("Select a model:"), "picker missing:\n{screen}");
        assert!(
            screen.contains("Smart — balanced (current)"),
            "server default should be marked current:\n{screen}"
        );
        assert!(
            !screen.contains("Generate a command or ask a question"),
            "input should be hidden while picking:\n{screen}"
        );

        h.press(KeyCode::Down);
        h.press(KeyCode::Enter);

        let app = h.app();
        assert!(app.fsm.ctx.model_picker.is_none(), "picker should close");
        assert_eq!(app.fsm.ctx.model.as_deref(), Some("fast"));
        // Input returns, and the status bar reflects the selection.
        let screen = h.screen();
        assert!(screen.contains("Generate a command or ask a question"));
        assert!(screen.contains("Model: fast"), "status bar:\n{screen}");
    }

    #[rstest]
    fn esc_closes_the_picker_without_exiting() {
        let mut h = Harness::new(app_with(AgentFsm::new(vec![], "t".into())));
        h.type_str("/model");
        h.press(KeyCode::Enter);
        h.stream(Event::ModelListLoaded(Ok(model_list())));
        h.press(KeyCode::Down);

        assert_eq!(h.press(KeyCode::Esc), None, "Esc closes, not exits");
        assert!(h.app().fsm.ctx.model_picker.is_none());
        assert_eq!(h.app().model_select.cursor, 0, "cursor resets when the picker closes");
    }

    #[rstest]
    fn typing_does_not_reach_the_editor_while_picking() {
        let mut h = Harness::new(app_with(AgentFsm::new(vec![], "t".into())));
        h.type_str("/model");
        h.press(KeyCode::Enter);
        h.stream(Event::ModelListLoaded(Ok(model_list())));

        h.type_str("stray keys");
        assert!(h.app().is_input_blank(), "editor must not receive keys while the picker is open");
    }

    #[rstest]
    fn status_bar_shows_usage_over_threshold() {
        let mut h = Harness::new(app_with(AgentFsm::new(vec![], "t".into())));
        let bucket_hot = crate::usage::UsageBucket {
            used: 92,
            limit: 100,
        };
        let bucket_cool = crate::usage::UsageBucket {
            used: 1,
            limit: 100,
        };
        h.process(Msg::Usage(crate::usage::UsageSnapshot {
            period: "calendar_monthly".into(),
            resets_at: "2099-01-01T00:00:00Z".into(),
            requests: crate::usage::UsageBucket { used: 0, limit: -1 },
            input: bucket_cool,
            output: bucket_hot,
        }));

        let screen = h.screen();
        assert!(screen.contains("92%"), "usage percent missing:\n{screen}");
        assert!(screen.contains("resets in"), "reset delta missing:\n{screen}");
    }

    /// Run one full exchange so the frontier is past zero, then /new.
    #[fixture]
    fn harness_after_new_session() -> Harness {
        let mut h = Harness::new(app_with(AgentFsm::new(vec![], "t".into())));
        h.type_str("first question");
        h.press(KeyCode::Enter);
        h.stream(Event::StreamStarted);
        h.stream(Event::StreamChunk("first answer".into()));
        h.stream(Event::StreamDone {
            session_id: "s1".into(),
        });
        assert!(h.app().pushed_events > 0, "exchange should have pushed");
        h.type_str("/new");
        h.press(KeyCode::Enter);
        h
    }

    #[rstest]
    fn new_session_notice_renders(#[from(harness_after_new_session)] h: Harness) {
        let all = h.all_lines();
        assert!(all.contains("Started a new session."), "/new notice missing:\n{all}");
    }

    #[rstest]
    fn response_after_new_session_survives_stream_end(
        #[from(harness_after_new_session)] mut h: Harness,
    ) {
        h.type_str("second question");
        h.press(KeyCode::Enter);
        // The user's own message must be visible while waiting.
        assert!(
            h.all_lines().contains("second question"),
            "submitted question missing:\n{}",
            h.all_lines()
        );

        h.stream(Event::StreamStarted);
        h.stream(Event::StreamChunk("the second answer".into()));
        assert!(
            h.screen().contains("the second answer"),
            "streaming text missing mid-stream:\n{}",
            h.screen()
        );

        h.stream(Event::StreamDone {
            session_id: "s2".into(),
        });
        let all = h.all_lines();
        assert!(all.contains("the second answer"), "response vanished on stream end:\n{all}");
    }

    #[rstest]
    fn skill_dispatches_even_when_registered_for_autocomplete() {
        let mut registry = SlashCommandRegistry::default();
        let mut skill_names = HashSet::new();
        registry.register(crate::tui::slash::SlashCommand::new("release", "Cut a release"));
        skill_names.insert("release".to_string());
        let app = AiApp::headless(AgentFsm::new(vec![], "t".into()), None, registry, skill_names);

        assert!(matches!(
            app.dispatch_submit("/release 1.2".into()),
            Event::RequestSkillLoad { name, arguments }
                if name == "release" && arguments.as_deref() == Some("1.2")
        ));
    }

    #[rstest]
    fn builtin_takes_precedence_over_skill_with_same_name() {
        let mut registry = SlashCommandRegistry::default();
        let mut skill_names = HashSet::new();
        registry
            .register(crate::tui::slash::SlashCommand::new("reload", "A skill shadowing /reload"));
        skill_names.insert("reload".to_string());
        let app = AiApp::headless(AgentFsm::new(vec![], "t".into()), None, registry, skill_names);

        assert!(matches!(app.dispatch_submit("/reload".into()), Event::SlashCommand { .. }));
    }

    #[rstest]
    fn resume_notice_renders_above_turns() {
        let mut fsm = AgentFsm::new(vec![], "test-invocation".into());
        fsm.ctx.events.push(ConversationEvent::UserMessage {
            content: "hello".into(),
        });
        let app = AiApp::headless(
            fsm,
            Some("  Continuing previous session".into()),
            SlashCommandRegistry::default(),
            HashSet::new(),
        );

        let h = Harness::new(app);
        let lines = h.term.viewport_lines();
        let notice_row = lines
            .iter()
            .position(|l| l.contains("Continuing previous session"))
            .expect("notice missing");
        let user_row = lines.iter().position(|l| l.contains(" You")).expect("user label missing");
        assert!(notice_row < user_row);
    }
}
