//! Prompt and bash-command submission dispatchers and reload-window helpers.

use super::auth::{scrollback_has_recent_context_too_large, scrollback_has_recent_reauth_prompt};
use super::ctx::with_active_agent;
use super::interject;
use super::permissions::drain_permission_queue;
use super::queue::{
    apply_turn_start_shim, drain_prompt_state_to_last_queued, immediate_server_send_eligible,
    maybe_drain_queue, push_server_queue_echo, retire_optimistic_echo,
};
use super::router::dispatch;
use super::session::fork::open_project_question;
use super::session::lifecycle::skip_picker_and_create_session;
use crate::app::actions::{Action, Effect};
use crate::app::agent::{AgentId, AgentState};
use crate::app::agent_view::AgentView;
use crate::app::app_view::{ActiveView, AppView};
use crate::notifications::{NotificationEvent, NotificationEventKind};
use crate::scrollback::block::RenderBlock;
use crate::scrollback::blocks::SessionEvent;
use agent_client_protocol as acp;

/// Chat kind for the next create: CLI `--chat` (`app.chat_mode`) or one-shot
/// `/chat` (`deferred_startup.pending_chat`, consumed here).
pub(super) fn consume_chat_kind(app: &mut AppView) -> bool {
    let pending = std::mem::take(&mut app.deferred_startup.pending_chat);
    app.chat_mode || pending
}

/// Enqueue a prompt and try to drain immediately.
///
/// The prompt is always pushed to the queue first. If the agent is idle
/// (and has a session), `maybe_drain_queue` pops the front prompt and
/// sends it in the same dispatch call — no deferred ticks.
/// Start (if needed) and submit the initial prompt from `kigi "<prompt>"`.
///
/// Shared by the TUI startup path (already authenticated) and the post-login
/// `AuthComplete` path (deferred via `deferred_startup.prompt`). It does nothing
/// special for auth or session lifecycle: it reuses the exact `NewSession` /
/// `SendPrompt` actions the welcome screen dispatches, so the normal
/// session-creation + enqueue/drain machinery carries the prompt (the
/// prompt waits in the queue until `SessionCreated`/`SessionLoaded` drains
/// it). `NewSession` is only dispatched when no session is active yet — a
/// `--resume`/`-c`/`-w` session started earlier in startup is reused.
pub(crate) fn dispatch_initial_prompt(app: &mut AppView, prompt: String) -> Vec<Effect> {
    let mut effects = Vec::new();
    if !matches!(app.active_view, ActiveView::Agent(_)) {
        effects.extend(dispatch(Action::NewSession, app));
    }
    effects.extend(dispatch(Action::SendPrompt(prompt), app));
    effects
}

pub(super) fn dispatch_send_prompt(app: &mut AppView, text: String) -> Vec<Effect> {
    crate::unified_log::info(
        "prompt.enqueue",
        None,
        Some(serde_json::json!({"len": text.len()})),
    );
    dispatch_send_prompt_inner(
        app, text, /* consume_input */ true, /* literal */ false,
        /* is_follow_up */ false,
    )
}

/// Clear the active prompt and record non-empty text in prompt history (Esc Esc).
pub(super) fn dispatch_clear_prompt(app: &mut AppView) -> Vec<Effect> {
    with_active_agent(app, |agent| {
        let text = agent.prompt.text().to_string();
        // Same move-to-front / cap as send / interject.
        interject::record_interject_prompt_history(agent, &text);
        // Clears chips/images via PromptWidget::set_text empty path.
        agent.prompt.set_text("");
    });
    vec![]
}

/// Open the prompt-history search panel on the active agent (composer as
/// filter query). Dispatched by `/history`; the slash pipeline has already
/// cleared the composer, so the panel opens with an empty query.
pub(super) fn dispatch_open_history_search(app: &mut AppView) -> Vec<Effect> {
    with_active_agent(app, |agent| {
        let history = agent.combined_prompt_history();
        let current_text = agent.prompt.text().to_string();
        agent
            .prompt
            .history_search
            .activate(&history, &current_text);
    });
    vec![]
}

/// Show the "ctrl+z to undo" hint after the user wiped a substantial draft.
/// Gated by the per-tip `contextual_hints.undo` gate (default ON). The
/// ephemeral-tip seen gate caps it at `UNDO_TIP_SEEN_CAP` shows per session
/// (in-memory `app.tip_seen_counts`); nothing is persisted to disk.
pub(super) fn dispatch_show_undo_tip(app: &mut AppView) -> Vec<Effect> {
    if !app.contextual_hints.undo {
        return vec![];
    }
    let ActiveView::Agent(id) = app.active_view else {
        return vec![];
    };
    let Some(agent) = app.agents.get_mut(&id) else {
        return vec![];
    };
    // Shows and increments the per-session count in place (no disk write).
    // Emit the impression only when the tip actually took the slot (mirrors
    // the `tip.shown` gate), so gated no-ops and TTL refreshes don't count.
    agent.show_ephemeral_tip(
        crate::tips::clear_detector::undo_tip(),
        &mut app.tip_seen_counts,
    );
    vec![]
}

/// Show the one-shot "Tight on space? Try /compact-mode" hint after the first
/// stable agent-view draw landed in the small-screen band (the trigger gates
/// on band + user compact OFF; see `AppView::maybe_trigger_small_screen_tip`).
/// Gated by the per-tip `contextual_hints.small_screen` gate (default ON).
/// Seen-gated in-memory via `app.tip_seen_counts`; nothing persists to disk.
///
/// Called directly from the draw-path trigger — not routed as an `Action`,
/// so it returns `()` and "no effects from draw" holds structurally.
pub(in crate::app) fn show_small_screen_tip(app: &mut AppView) {
    if !app.contextual_hints.small_screen {
        return;
    }
    let ActiveView::Agent(id) = app.active_view else {
        return;
    };
    let Some(agent) = app.agents.get_mut(&id) else {
        return;
    };
    // Impression only when the tip actually takes the slot (mirrors undo/plan).
    if agent.show_ephemeral_tip(
        crate::tips::small_screen::small_screen_tip(),
        &mut app.tip_seen_counts,
    ) {}
}

pub(super) fn dispatch_show_plan_nudge(app: &mut AppView) -> Vec<Effect> {
    if !app.contextual_hints.plan_mode {
        return vec![];
    }
    let ActiveView::Agent(id) = app.active_view else {
        return vec![];
    };
    let Some(agent) = app.agents.get_mut(&id) else {
        return vec![];
    };
    // Shows and increments the per-session count in place (no disk write).
    // Impression counts only on a real show (see `dispatch_show_undo_tip`).
    agent.show_ephemeral_tip(
        crate::tips::plan_nudge::plan_nudge_tip(),
        &mut app.tip_seen_counts,
    );
    vec![]
}

/// After a fold/nav double-click on scrollback, tip that Word select lives in
/// `/settings`. Gated by `contextual_hints.word_select` (default ON).
pub(super) fn dispatch_show_word_select_tip(app: &mut AppView) -> Vec<Effect> {
    if !app.contextual_hints.word_select {
        return vec![];
    }
    // Already on word_select — tip would be wrong / redundant.
    if crate::appearance::cache::load_keep_text_selection().selects_word() {
        return vec![];
    }
    let ActiveView::Agent(id) = app.active_view else {
        return vec![];
    };
    let Some(agent) = app.agents.get_mut(&id) else {
        return vec![];
    };
    agent.show_ephemeral_tip(
        crate::tips::word_select::word_select_tip(),
        &mut app.tip_seen_counts,
    );
    // Snapshot the prompt as of this double-click (also on a same-key TTL
    // refresh — a new double-click is a new moment). Any later divergence
    // (typed, pasted, dropped) refuses the chord and retires the tip; a
    // seen-cap-gated no-show leaves the slot to another tip and skips this.
    if agent.ephemeral_tip.current_key() == Some(crate::tips::word_select::WORD_SELECT_TIP_KEY) {
        agent.word_select_tip_prompt_snapshot = Some(agent.prompt.text().to_string());
    }
    vec![]
}

/// Accept the word-select tip via its advertised chord: flip
/// `keep_text_selection` to `word_select` (cache + persist + toast, the same
/// path as the settings modal) and retire the tip so one impression maps to
/// at most one acceptance. No-op unless the tip is on screen — the chord is
/// tip-scoped and must not become a global setting toggle.
pub(super) fn dispatch_accept_word_select_tip(app: &mut AppView) -> Vec<Effect> {
    let ActiveView::Agent(id) = app.active_view else {
        return vec![];
    };
    let Some(agent) = app.agents.get_mut(&id) else {
        return vec![];
    };
    if agent.ephemeral_tip.current_key() != Some(crate::tips::word_select::WORD_SELECT_TIP_KEY) {
        return vec![];
    }
    agent
        .ephemeral_tip
        .clear(crate::tips::word_select::WORD_SELECT_TIP_KEY);
    agent.word_select_tip_prompt_snapshot = None;
    super::settings::setters::set_keep_text_selection(
        app,
        crate::appearance::TextSelection::WordSelect,
    )
}

/// After queuing a follow-up mid-turn, tip that empty Enter force-sends the top
/// queued item. Gated by the per-tip `contextual_hints.send_now` gate (default
/// ON). Seen-gated in-memory via `app.tip_seen_counts`.
fn maybe_show_send_now_tip(app: &mut AppView) {
    if !app.contextual_hints.send_now {
        return;
    }
    let ActiveView::Agent(id) = app.active_view else {
        return;
    };
    let Some(agent) = app.agents.get_mut(&id) else {
        return;
    };
    // Impression only when the tip actually takes the slot (mirrors undo/plan).
    if agent.show_ephemeral_tip(
        crate::tips::send_now::send_now_tip(),
        &mut app.tip_seen_counts,
    ) {}
}

/// Whether submitting `text` may open the project picker. Slash commands,
/// `exit`/`quit` aliases, and empty input never send a prompt to the agent,
/// so they must pass through untouched.
pub(super) fn input_can_trigger_project_picker(text: &str) -> bool {
    let t = text.trim();
    !t.is_empty()
        && !t.starts_with('/')
        && !t.starts_with('!')
        && !matches!(t, "exit" | "quit" | ":q" | ":q!" | ":wq" | ":wq!")
}

/// Body of [`dispatch_send_prompt`], parameterized over whether to consume
/// the prompt textarea after the command is processed.
///
/// `consume_input = true` (Enter from the prompt) wipes the textarea, drains
/// pending prompt images into the queue, and inserts the text into the local
/// up-arrow history. `consume_input = false` (modal-driven dispatch from the
/// command palette or ArgPicker) preserves the user's draft, leaves prompt
/// images attached, and skips the history insert. The slash-registry
/// resolution and the downstream `Effect`s are identical in both cases.
///
/// `literal = true` (follow-up chip click) submits `text` straight to the
/// model: the slash-command, exit-alias, and project-picker branches are all
/// skipped so server/model-controlled chip text can never execute a command
/// nor be diverted into the project question.
pub(super) fn dispatch_send_prompt_inner(
    app: &mut AppView,
    text: String,
    consume_input: bool,
    literal: bool,
    is_follow_up: bool,
) -> Vec<Effect> {
    // Submitting is a fresh intent that retires any armed double-press. The
    // AppView pending-action check only resets on KEY events, so a submit with
    // no intervening key (mouse send, follow-up chip click `SubmitFollowUp`,
    // `SendSlashCommandPreservingDraft`) would otherwise leave a stale arm
    // (e.g. an idle-Esc `ClearPrompt`) that shadows the next Esc — firing stale
    // ClearPrompt|Rewind instead of the mid-turn swallow until TTL. Cleared in
    // the common funnel so every submit path is covered, before any early-return
    // guard below.
    app.pending_action = None;
    if app.reconnect_pending {
        app.show_toast("Reconnecting, please wait...");
        return vec![];
    }

    // The picker intercepts only real, user-authored prompts; slash commands,
    // exit aliases, empty input, and literal chip submissions pass through so
    // they never spawn it (a chip is a model suggestion for an already-running
    // session, not the first-prompt project choice).
    if !literal && input_can_trigger_project_picker(&text) && app.needs_project_picker() {
        return open_project_question(app, text);
    }

    let ActiveView::Agent(id) = app.active_view else {
        return vec![];
    };
    // Capture app-level fields before the mut-borrow on `agent`.
    let show_tips_from_app = app.show_tips;
    let auto_update_from_app = app.auto_update;
    let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds;
    let auto_mode_gate_from_app = app.auto_mode_gate;
    let ask_user_question_timeout_enabled_from_app = app.ask_user_question_timeout_enabled;
    // Set when a plain prompt is queued while a turn is running (local path);
    // shown after the agent borrow ends so we can re-enter via the tip helper.
    let mut tip_send_now_after_queue = false;
    let Some(agent) = app.agents.get_mut(&id) else {
        return vec![];
    };

    // Paste-then-immediate-send: an image probe from a just-pasted Cmd+V is
    // still off-thread. Stash this send and re-issue it once the probe completes
    // so the image is never dropped from the built content blocks. Scoped to
    // `consume_input` sends: only those clear the draft, so only they can drop a
    // not-yet-attached image — draft-preserving sends (follow-up chip,
    // slash-preserving) keep it in the draft for the next real send.
    if consume_input && agent.paste_probe_in_flight > 0 {
        agent.deferred_send = Some(crate::app::agent_view::AgentDeferredSend::SendPrompt);
        return vec![];
    }

    // Submitting the prompt retires any edit-contextual ephemeral tip
    // (ambient tips live out their TTL across the submit).
    agent.ephemeral_tip.clear_on_submit();

    let trimmed = text.trim();

    let mut effects = Vec::new();

    // ── Registry-based slash command execution ─────────────────────
    // If the text starts with `/`, run it through the slash registry.
    // The registry resolves builtins, ACP-advertised commands, and
    // unknown commands uniformly. Dispatch is the SOLE execution owner.
    // `literal` (chip click) skips this so chip text is never a command.
    if !literal && trimmed.starts_with('/') {
        use crate::slash::command::{CommandExecCtx, CommandResult};
        use crate::slash::parse_invocation;

        // A required-args command submitted bare (`/model` + Enter): don't
        // run into its usage error — re-open the prompt in the args phase
        // (`"/model "`) so the existing suggestion dropdown lists the
        // choices. This is the documented "Blocks" row of
        // [`crate::slash::is_command_complete`].
        if let Some(invocation) = parse_invocation(trimmed)
            && !crate::slash::is_command_complete(trimmed, agent.prompt.slash_controller.registry())
        {
            let reopened = format!("/{} ", invocation.token);
            agent.prompt.set_text(&reopened);
            // Cursor past the trailing space — that is the args-phase signal
            // the suggestion controller keys on.
            agent.prompt.set_cursor(reopened.len());
            agent.prompt.refresh_slash(&agent.session.models);
            return vec![];
        }

        // Build execution context.
        let exec_result = {
            let mut ctx = CommandExecCtx {
                models: &agent.session.models,
                session_id: agent.session.session_id.as_ref(),
                bundle_state: &app.bundle_state,
                screen_mode: app.screen_mode,
                // PAGER-owned snapshot for slash commands.
                pager_state: crate::settings::PagerLocalSnapshot {
                    multiline_mode: agent.multiline_mode,
                    yolo_mode: agent.session.is_yolo(),
                    auto_mode: agent.session.is_auto(),
                    current_model_name: agent.session.models.current_model_name(),
                    available_models: agent
                        .session
                        .models
                        .available
                        .iter()
                        .map(|(id, info)| (info.name.clone(), id.clone()))
                        .collect(),
                    // Prefer optimistic pending over confirmed active.
                    plan_mode_active: agent.plan_mode_pending.unwrap_or(agent.plan_mode_active),
                    show_tips: show_tips_from_app,
                    auto_update: auto_update_from_app,
                    vim_mode: crate::appearance::cache::load_vim_mode(),
                    scroll_speed: crate::appearance::cache::load_scroll_speed(),
                    respect_manual_folds: respect_manual_folds_from_app,
                    auto_mode_gate: auto_mode_gate_from_app,
                    ask_user_question_timeout_enabled: ask_user_question_timeout_enabled_from_app,
                },
            };

            if let Some(invocation) = parse_invocation(trimmed) {
                // Bypasses only the menu-only hide (hard gates still
                // return `None`); see `CommandRegistry::get_for_dispatch`.
                let command = agent
                    .prompt
                    .slash_controller
                    .registry()
                    .get_for_dispatch(invocation.token)
                    .cloned();
                if let Some(command) = command {
                    if ctx.screen_mode.is_minimal() && !command.available_in_minimal() {
                        // Central minimal gate: commands that drive the deleted
                        // fullscreen pane / dashboard (/find, /copy, /dashboard)
                        // have nothing to act on in scrollback-native mode.
                        // Surface a friendly system block instead of running them.
                        CommandResult::Message(format!(
                            "/{} is not available in minimal mode",
                            invocation.token
                        ))
                    } else {
                        agent
                            .prompt
                            .slash_controller
                            .record_command_use(invocation.token, invocation.token);
                        command.run(&mut ctx, invocation.args)
                    }
                } else {
                    // Unknown command -- pass through to shell.
                    CommandResult::PassThrough(text.clone())
                }
            } else {
                // Bare `/` or malformed -- pass through.
                CommandResult::PassThrough(text.clone())
            }
        };

        // Map CommandResult to pager behavior. (MRU persistence is queued
        // off-thread inside `record_command_use` above.)
        match exec_result {
            CommandResult::Handled | CommandResult::HandledNoOp => {
                if consume_input {
                    agent.prompt.set_text("");
                }
                return vec![];
            }
            CommandResult::Error(msg) => {
                if consume_input {
                    agent.prompt.set_text("");
                }
                agent.scrollback.push_block(RenderBlock::system(msg));
                return vec![];
            }
            CommandResult::Message(msg) => {
                if consume_input {
                    agent.prompt.set_text("");
                }
                agent.scrollback.push_block(RenderBlock::system(msg));
                return vec![];
            }
            CommandResult::Action(Action::ExitSession) => {
                if consume_input {
                    agent.prompt.set_text("");
                }
                return dispatch(Action::ExitSession, app);
            }
            CommandResult::Action(action) => {
                if consume_input {
                    agent.prompt.set_text("");
                }
                return dispatch(action, app);
            }
            CommandResult::QueueCommand(cmd_text) => {
                agent.session.enqueue_command(cmd_text);
            }
            CommandResult::InjectSkill {
                display_text,
                prompt_blocks,
                display_as_skill,
                scheduled_task_preview,
            } => {
                // Enqueue with display text for scrollback but wire_blocks
                // for the actual prompt sent to the model. Leading skill
                // invocation: display_as_skill owns styling (no ranges).
                let id = agent.session.next_queue_id;
                agent.session.next_queue_id += 1;
                agent
                    .session
                    .pending_prompts
                    .push_back(crate::app::agent::QueuedPrompt {
                        wire_blocks: Some(prompt_blocks),
                        display_as_skill,
                        ..crate::app::agent::QueuedPrompt::plain(
                            id,
                            display_text,
                            crate::app::agent::QueueEntryKind::Prompt,
                        )
                    });

                // Insert a provisional scheduled task so the tasks pane shows
                // it immediately, before the LLM round-trips through
                // scheduler_create. Keyed by a provisional ID; replaced when
                // the real ScheduledTaskCreated notification arrives.
                if let Some(preview) = scheduled_task_preview {
                    use crate::app::agent::ScheduledTaskInfo;
                    let provisional_id = format!("provisional-{}", id);
                    agent.session.scheduled_tasks.insert(
                        provisional_id.clone(),
                        ScheduledTaskInfo {
                            task_id: provisional_id,
                            prompt: preview.prompt,
                            human_schedule: preview.human_schedule,
                            created_at: std::time::Instant::now(),
                            next_fire_at: preview.next_fire_at,
                            tag: preview.tag,
                        },
                    );
                }
            }
            CommandResult::PassThrough(pass_text) => {
                // A recognized token later in the passthrough text still styles the echo.
                let skill_token_ranges = agent
                    .prompt
                    .slash_controller
                    .recognized_token_ranges(&pass_text, &agent.session.models);
                agent
                    .session
                    .enqueue_prompt_with_skill_tokens(pass_text, skill_token_ranges);
            }
        }
        if consume_input {
            // Drain prompt images before clearing prompt state.
            drain_prompt_state_to_last_queued(agent);
            agent.prompt.set_text("");
        }
        // Every non-enqueue arm returned above. Queued slash work bypasses
        // the picker, so create the deferred session or it never drains.
        effects = skip_picker_and_create_session(app, id);
    } else if !literal && matches!(trimmed, "exit" | "quit" | ":q" | ":q!" | ":wq" | ":wq!") {
        if consume_input {
            agent.prompt.set_text("");
        }
        return dispatch(Action::Quit, app);
    } else {
        // ── Server-authoritative immediate send (plain prompt only) ──
        // A plain prompt typed while a turn is RUNNING is sent to the agent
        // immediately instead of being held in the local drip-feed queue. The
        // agent appends it to its authoritative `pending_inputs` (no concurrent
        // turn starts — validated keystone) and drives the drain via
        // `kigi/queue/changed`. We render an optimistic echo into the shared
        // queue keyed by `prompt_id`; the broadcast reconciles it by id.
        //
        // The IDLE case is unchanged (falls through to the local path below,
        // which drains instantly and renders the user block) — preserving the
        // byte-for-byte idle experience. Image/skill/editing/non-running cases
        // also stay local; they're out of immediate-send scope.
        // Plain prompts also require "no images" (image prompts stay local).
        //
        // A follow-up chip submission supersedes the current response's
        // suggestions: clear the visible chips here — INSIDE the send/enqueue
        // path, after the `reconnect_pending` and active-agent early-return
        // guards — so the chips are cleared ONLY when the suggestion actually
        // sends/enqueues. Placing it before those guards (the prior fix) cleared
        // the chips even when `reconnect_pending` aborted with a toast and no
        // send, losing both the chips and the submit. This single clear covers
        // BOTH the immediate-send and enqueue subpaths below; `clear_follow_ups`
        // is idempotent (so the immediate-send branch's own clear is a no-op)
        // and keeps `follow_up_seen` (a stale re-delivery stays rejected).
        //
        // Gate on a BOUND session: with no `session_id`, the enqueue subpath
        // below queues the text but `maybe_drain_queue` returns WITHOUT emitting
        // `SendPrompt` (nothing can drain to an unbound session), so clearing the
        // chips here would lose the click with nothing submitted. Leaving them
        // shown preserves the suggestion for a retry once the session binds.
        if is_follow_up && agent.session.session_id.is_some() {
            agent.clear_follow_ups();
        }

        // If the user queues a follow-up while a turn is already running, surface
        // a short tip advertising send-now — plain Enter queues; Enter again on
        // the emptied composer sends the queued message now (cancel-and-send).
        let queued_while_running = agent.session.state.is_turn_running();

        // Composer-recognized slash tokens at submit time: styles the
        // scrollback echo and rides the wire meta so replay restyles it.
        let skill_token_ranges = agent
            .prompt
            .slash_controller
            .recognized_token_ranges(&text, &agent.session.models);

        let immediate_server_send =
            immediate_server_send_eligible(agent) && agent.prompt.images.is_empty();
        tracing::debug!(
            target: "qtrace",
            pid = std::process::id(),
            event = "send_route_plain",
            immediate = immediate_server_send,
            is_turn_running = agent.session.state.is_turn_running(),
            shared_queue_len = agent.shared_queue.len(),
            pending_len = agent.session.pending_prompts.len(),
            current_prompt_id = agent.session.current_prompt_id.as_deref().unwrap_or(""),
            session = agent.session.session_id.as_ref().map(|s| s.0.as_ref()).unwrap_or(""),
            images = agent.prompt.images.len(),
            text = %text.chars().take(48).collect::<String>(),
            "plain prompt send routing decision",
        );

        // Parked + held occupancy → append; empty held → cancel-and-send.
        let parked_sendable_wait = agent.is_parked_on_sendable_wait();
        let hold_behind_existing_queue = parked_sendable_wait && agent.has_held_user_queue();

        // Images can't ride immediate server-send; empty-held park still send-nows.
        if !immediate_server_send
            && immediate_server_send_eligible(agent)
            && !agent.prompt.images.is_empty()
            && parked_sendable_wait
            && !hold_behind_existing_queue
        {
            let images = agent.prompt.drain_images();
            if consume_input {
                agent.prompt.set_text("");
            }
            // A new prompt is taking the wheel (same contract as the
            // immediate-send branch below).
            agent.clear_follow_ups();
            return interject::dispatch_send_prompt_now(app, text, images);
        }

        if immediate_server_send {
            let session_id = agent
                .session
                .session_id
                .clone()
                .expect("session_id is_some checked");
            let agent_id = agent.session.id;
            let prompt_id = uuid::Uuid::new_v4().to_string();
            // Self-originated: when this prompt becomes the running turn (via the
            // `running_prompt_id` adoption + turn-start shim), the ACP gate must
            // treat its deltas as ours, not adopt them as another client's turn.
            agent.note_self_originated_prompt(&prompt_id);

            if parked_sendable_wait && !hold_behind_existing_queue {
                agent.arm_send_now_expectation(prompt_id.clone());
                agent.suppress_parked_marker_on_interject();
            }

            if consume_input {
                // Plain prompt: no images to drain. Clear textarea + record
                // up-arrow history (same as the local path's history insert).
                agent.prompt.set_text("");
                let trimmed_key = text.trim().to_string();
                if !trimmed_key.is_empty() {
                    agent
                        .session
                        .prompt_history
                        .retain(|p| p.trim() != trimmed_key);
                    agent.session.prompt_history.insert(0, text.clone());
                    if agent.session.prompt_history.len() > 200 {
                        agent.session.prompt_history.truncate(200);
                    }
                }
            }

            // A new prompt is taking the wheel: the previous response's
            // follow-up chips must not linger into it. The local drain
            // (`maybe_drain_queue`) and the turn-start shim clear them on
            // their paths; this immediate-send path returns early, so it must
            // clear them here too (notably a chip click, which submits while
            // a turn is running). `clear_follow_ups` keeps `follow_up_seen`
            // (turn-boundary semantics) so a stale re-delivery stays rejected.
            agent.clear_follow_ups();

            // `agent` borrow ends here; push the optimistic echo via `app`.
            let sid_str = session_id.0.to_string();
            push_server_queue_echo(app, agent_id, &sid_str, &prompt_id, &text, "prompt");
            crate::unified_log::info(
                "prompt.send_server_authoritative",
                Some(&sid_str),
                Some(serde_json::json!({ "kind": "prompt", "len": text.len() })),
            );
            if queued_while_running && !parked_sendable_wait {
                maybe_show_send_now_tip(app);
            }
            return vec![Effect::SendPrompt {
                agent_id,
                session_id,
                text,
                prompt_id,
                skill_token_ranges,
            }];
        }

        agent
            .session
            .enqueue_prompt_with_skill_tokens(text.clone(), skill_token_ranges);
        if consume_input {
            // Drain prompt images before clearing prompt state.
            drain_prompt_state_to_last_queued(agent);
            agent.prompt.set_text("");
        }
        // Local queue while a turn is running (e.g. images attached): tip after
        // this branch so the agent mut-borrow is released first.
        tip_send_now_after_queue = queued_while_running;
    }

    // Mid-turn local queue: advertise send-now via the ephemeral tip (skip during
    // a sendable wait — the inline hint already says it).
    if tip_send_now_after_queue {
        let inline_hint_shown = app
            .agents
            .get(&id)
            .is_some_and(|agent| agent.held_queue_count() > 0);
        if !inline_hint_shown {
            maybe_show_send_now_tip(app);
        }
    }

    {
        let Some(agent) = app.agents.get_mut(&id) else {
            return effects;
        };

        // Insert into local prompt history (move-to-front dedup, cap at 200).
        // Skipped for modal-driven dispatch: the user didn't type these
        // commands and shouldn't see them in up-arrow history.
        if !consume_input {
            effects.extend(maybe_drain_queue(agent));
        } else {
            let trimmed_key = text.trim().to_string();
            if !trimmed_key.is_empty() {
                agent
                    .session
                    .prompt_history
                    .retain(|p| p.trim() != trimmed_key);
                agent.session.prompt_history.insert(0, text.clone());
                if agent.session.prompt_history.len() > 200 {
                    agent.session.prompt_history.truncate(200);
                }
            }
            effects.extend(maybe_drain_queue(agent));
        }
    }
    effects
}

/// Enqueue a bash command and try to drain immediately.
///
/// Bash commands go through the same enqueue/drain pipeline as normal prompts,
/// just with `QueueEntryKind::BashCommand`. No scrollback block is pushed here;
/// the execute block from the shell IS the visual entry.
pub(super) fn dispatch_send_bash_command(app: &mut AppView, command: String) -> Vec<Effect> {
    if app.reconnect_pending {
        app.show_toast("Reconnecting, please wait...");
        return vec![];
    }

    let ActiveView::Agent(id) = app.active_view else {
        return vec![];
    };
    let Some(agent) = app.agents.get_mut(&id) else {
        return vec![];
    };
    // Submitting a bash command retires any edit-contextual ephemeral tip.
    agent.ephemeral_tip.clear_on_submit();
    if agent.session.session_id.is_none() {
        let effects = skip_picker_and_create_session(app, id);
        if let Some(agent) = app.agents.get_mut(&id) {
            agent.session.enqueue_bash_command(command);
            agent.prompt.set_text("");
        }
        return effects;
    }

    // Store in prompt history with `! ` prefix for restore semantics.
    let history_key = format!("! {}", command.trim());
    agent
        .session
        .prompt_history
        .retain(|p| p.trim() != history_key);
    agent.session.prompt_history.insert(0, history_key);
    if agent.session.prompt_history.len() > 200 {
        agent.session.prompt_history.truncate(200);
    }

    // ── Server-authoritative immediate send for bash while running ──
    // A bash command typed while a turn is RUNNING is sent to the agent
    // immediately (it's already a `session/prompt` with bash meta) and echoed
    // into the shared queue with `kind="bash"`. On `running_prompt_id`
    // adoption the turn-start shim sets `bash_turn` (no user block). The IDLE
    // case is unchanged: enqueue locally + drain instantly.
    let bash_immediate = immediate_server_send_eligible(agent);
    tracing::debug!(
        target: "qtrace",
        pid = std::process::id(),
        event = "send_route_bash",
        immediate = bash_immediate,
        is_turn_running = agent.session.state.is_turn_running(),
        shared_queue_len = agent.shared_queue.len(),
        pending_len = agent.session.pending_prompts.len(),
        current_prompt_id = agent.session.current_prompt_id.as_deref().unwrap_or(""),
        session = agent.session.session_id.as_ref().map(|s| s.0.as_ref()).unwrap_or(""),
        text = %command.chars().take(48).collect::<String>(),
        "bash command send routing decision",
    );
    if bash_immediate {
        let session_id = agent
            .session
            .session_id
            .clone()
            .expect("session_id is_some checked");
        let agent_id = agent.session.id;
        let prompt_id = uuid::Uuid::new_v4().to_string();
        // Self-originated (see the plain immediate-send path): keep this turn's
        // deltas ours in the ACP gate once it becomes the running turn.
        agent.note_self_originated_prompt(&prompt_id);
        agent.prompt.set_text("");

        let sid_str = session_id.0.to_string();
        push_server_queue_echo(app, agent_id, &sid_str, &prompt_id, &command, "bash");
        crate::unified_log::info(
            "prompt.send_server_authoritative",
            Some(&sid_str),
            Some(serde_json::json!({ "kind": "bash", "len": command.len() })),
        );
        return vec![Effect::SendBashCommand {
            agent_id,
            session_id,
            command,
            prompt_id,
        }];
    }

    agent.session.enqueue_bash_command(command.clone());
    agent.prompt.set_text("");

    maybe_drain_queue(agent)
}

/// Whether a load-result handler must stand down because a reconnect reload
/// window is open on the agent.
///
/// The window owns the agent's batch / `loading_replay` / turn state; a load
/// result resolving mid-window (a stale fresh-view load, or `/resume` racing a
/// reconnect) must not close it — flipping `loading_replay` would make the
/// replay gate drop the rest of the reconnect replay, and a failure block
/// would be pushed into staging state. The window finalize supersedes the
/// result.
pub(super) fn defer_to_open_reload_window(
    agent: &AgentView,
    agent_id: AgentId,
    result: &str,
) -> bool {
    if agent.session_reload.is_none() {
        return false;
    }
    tracing::warn!(
        agent = ?agent_id,
        result,
        "load result during an open reload window — deferring to the window finalize"
    );
    true
}

/// The initiation-side counterpart of [`defer_to_open_reload_window`]: a load
/// INITIATION that takes over the agent (fork/worktree-fork/remote-restore
/// binding a session) finalizes any open reload window as failed first, so
/// the new load owns the agent's batch/replay state and its results are not
/// deferred. Unreachable through today's flows (these arms target freshly
/// created `session_id: None` agents, which can never host a window) —
/// defense in depth against future initiation paths on live agents.
pub(super) fn supersede_open_reload_window(
    agent: &mut AgentView,
    agent_id: AgentId,
    initiation: &str,
) {
    if agent.session_reload.is_none() {
        return;
    }
    tracing::warn!(
        agent = ?agent_id,
        initiation,
        "load initiation supersedes an open reload window (finalizing as failed)"
    );
    agent.abort_session_reload();
}

// TaskResult handlers.

pub(super) fn handle_prompt_response(
    app: &mut AppView,
    agent_id: AgentId,
    result: Result<acp::PromptResponse, String>,
    http_status: Option<u16>,
    prompt_id: Option<String>,
) -> Vec<Effect> {
    // A server-authoritative queued prompt may have drained into
    // the running slot while this turn was still finishing (the leader's
    // `running_prompt_id` broadcast can arrive before this
    // `PromptResponse`). Take any stashed adoption now; it is applied
    // after `finish_turn` clears `current_prompt_id` below.
    let pending_adoption = app.pending_running_adoptions.remove(&agent_id);
    if let Some(agent) = app.agents.get_mut(&agent_id) {
        // Discard PromptResponses that don't belong to the currently
        // active prompt -- they belong to a turn the user rewound, or to
        // a queued prompt that never became the running turn.
        //
        // The prompt id comes from two places depending on the arm:
        //   - `Ok`:  the agent echoes `promptId` in PR meta.
        //   - `Err`: an `acp::Error` carries NO meta, so we fall back to
        //            the `prompt_id` the pager minted when it sent this
        //            RPC (threaded through `TaskResult::PromptResponse`).
        //
        // Without the `Err` fallback, a queued prompt's RPC error has no
        // id to gate on and is misattributed to the running turn — e.g.
        // when a queued prompt is removed in leader mode and its
        // `respond_to` is dropped on the leader, the resulting
        // "session failed to respond" error would detonate an unrelated
        // in-flight turn with a spurious "Turn failed" (on the
        // submitter's screen, even when another client did the edit).
        let response_pid = match &result {
            Ok(pr) => pr
                .meta
                .as_ref()
                .and_then(|m| m.get("promptId"))
                .and_then(|v| v.as_str())
                .map(str::to_string),
            Err(_) => prompt_id.clone(),
        };
        // The turn-end RPC for this prompt arrived — disarm the
        // lost-response reconcile that `handle_prompt_complete` armed
        // for it (the broadcast is emitted before the RPC response, so
        // in the healthy path the marker lives only a few ms).
        if let Some(pending) = agent.pending_turn_end_reconcile.as_ref()
            && response_pid.as_deref() == Some(pending.prompt_id.as_str())
        {
            agent.pending_turn_end_reconcile = None;
        }
        if let Some(response_pid) = response_pid.as_deref()
            && agent.session.current_prompt_id.as_deref() != Some(response_pid)
        {
            if (agent.session.current_prompt_id.is_none()
                || agent
                    .session
                    .current_prompt_id
                    .as_deref()
                    .is_some_and(crate::app::acp_handler::is_server_initiated_prompt))
                && crate::app::acp_handler::is_server_initiated_prompt(response_pid)
            {
                // Server-initiated turn (auto-wake) — adopt.
                agent.session.current_prompt_id = Some(response_pid.to_string());
            } else {
                // Not the running turn: this response (Ok rewound/stale,
                // or Err from a queued/removed prompt) must not touch the
                // active turn. Restore the adoption we popped above so a
                // genuinely-draining next prompt can still be adopted by
                // the real running turn's PromptResponse — unless it is the
                // stashed turn's own response: that spent the turn's only
                // exit, so consume (discard), never restore.
                if let Some(p) = pending_adoption {
                    if p.prompt_id == response_pid {
                        agent.discard_pending_adoption_updates(&p.prompt_id);
                    } else {
                        app.pending_running_adoptions.insert(agent_id, p);
                    }
                }
                // Server-authoritative queue lifecycle: this prompt's RPC
                // resolved without becoming the running turn (removed,
                // cancelled, rewound). Retire its optimistic echo so a
                // later `kigi/queue/changed` broadcast can't re-pin a
                // stale placeholder and reorder the queue.
                if let Some(sid) = agent.session.session_id.as_ref().map(|s| s.0.to_string()) {
                    retire_optimistic_echo(
                        &mut app.optimistic_prompt_echoes,
                        &mut app.shared_prompt_queues,
                        &sid,
                        response_pid,
                    );
                    agent.shared_queue.retain(|e| e.id != response_pid);
                    agent.note_queue_echo_retired(response_pid);
                }
                // Resolved-without-running never adopts; explicit for the
                // session-less arm (no note_queue_echo_retired above).
                agent.retire_send_now_painted_block(response_pid);
                return vec![];
            }
        }
        let was_cancelling = agent.session.state.is_cancelling()
            || matches!(
                &result,
                Ok(pr) if pr.stop_reason == acp::StopReason::Cancelled
            );
        // Send-now cancel: suppress the "Turn cancelled by user" marker (the new
        // prompt follows right under the partial). Wire `cancelTrigger` wins, else
        // the client-side expectation; consumed at every turn end (no stale flag).
        let expected_send_now = agent.expect_send_now_cancel.take();
        let wire_cancel_trigger = result.as_ref().ok().and_then(|pr| {
            pr.meta
                .as_ref()?
                .get("cancelTrigger")?
                .as_str()
                .map(str::to_string)
        });
        let send_now_cancel = was_cancelling
            && match wire_cancel_trigger.as_deref() {
                Some(trigger) => trigger == "send_now",
                None => expected_send_now.is_some(),
            };
        let rate_limited = agent.session.rate_limited;
        let model_incompatible = agent.session.model_incompatible;
        // Context overflow: the RetryState handler already pushed the actionable
        // block, so the generic TurnFailed + error toast are redundant. Derived
        // from the scrollback (mirrors reauth), not a session flag.
        let context_overflow = scrollback_has_recent_context_too_large(&agent.scrollback);
        // A 401/auth failure already surfaced an actionable
        // `ReAuthRequired` prompt via the RetryState handler (which
        // runs before this PromptResponse). Suppress the redundant
        // "Turn failed" block + error toast so only the prompt shows.
        let reauth_prompted = scrollback_has_recent_reauth_prompt(&agent.scrollback)
            || (http_status == Some(401)
                && result
                    .as_ref()
                    .err()
                    .is_some_and(|e| e.contains("Unauthorized (401)")));
        let elapsed = agent.turn_elapsed();

        {
            let sid = agent.session.session_id.as_ref().map(|s| s.0.as_ref());
            let elapsed_ms = elapsed.map(|d| d.as_millis() as u64).unwrap_or(0);
            let ok = result.is_ok();
            crate::unified_log::info(
                "turn.complete",
                sid,
                Some(serde_json::json!({
                    "elapsed_ms": elapsed_ms,
                    "ok": ok,
                    "was_cancelling": was_cancelling,
                    "send_now_cancel": send_now_cancel,
                })),
            );
        }

        // Stash the prompt from a turn that failed on an
        // expired login (401 / re-auth). The AuthComplete handler
        // auto-resubmits it after a successful mid-session re-auth.
        // A non-rewindable turn (None) must not clobber an earlier stash.
        if reauth_prompted && let Some(prompt) = agent.session.in_flight_prompt.as_ref() {
            agent.reauth_stashed_prompt = Some(prompt.clone());
        }

        // qtrace: turn end on this client. This clears current_prompt_id
        // and (briefly) returns the client to Idle — the start of the
        // leader-mode turn-end window where a freshly-sent prompt can be
        // wrongly local-drained before the next running-prompt broadcast
        // is adopted.
        tracing::debug!(
            target: "qtrace",
            pid = std::process::id(),
            event = "turn_end",
            prompt_id = prompt_id.as_deref().unwrap_or(""),
            was_cancelling,
            shared_queue_len = agent.shared_queue.len(),
            pending_len = agent.session.pending_prompts.len(),
            has_pending_adoption = pending_adoption.is_some(),
            session = agent.session.session_id.as_ref().map(|s| s.0.as_ref()).unwrap_or(""),
            "turn ended; client returning to idle",
        );

        // Read before `finish_turn()` clears it; keys the pending stop-hook stash.
        let ending_prompt_id = agent
            .session
            .current_prompt_id
            .clone()
            .or_else(|| response_pid.clone());

        agent.session.finish_turn(&mut agent.scrollback);

        // Insert session event message (skip TurnCompleted for bash-mode — no agent turn).
        let event = match (&result, was_cancelling) {
            // Send-now cancel: no marker (the new prompt is the next turn); the
            // `None` still flushes any held stop hooks standalone.
            (Ok(_), true) if send_now_cancel => None,
            (Ok(_), true) => Some(SessionEvent::TurnCancelled {
                elapsed: elapsed.unwrap_or_default(),
            }),
            (Ok(_), false) if agent.bash_turn => None,
            (Ok(_), false) => Some(SessionEvent::TurnCompleted {
                // Legacy copy on purpose: unknown elapsed keeps the "in 0.0s"
                // form here — only wake markers use the honest `None` form.
                elapsed: Some(elapsed.unwrap_or_default()),
            }),
            (Err(_), _)
                if rate_limited || model_incompatible || reauth_prompted || context_overflow =>
            {
                // Skip TurnFailed when a dedicated prompt/modal shows instead
                // (rate limit, model incompatibility, 401 re-auth, or a
                // terminal context-window overflow).
                None
            }
            (Err(err), _) => Some(SessionEvent::TurnFailed {
                error: err.clone(),
                elapsed,
            }),
        };
        crate::app::turn_completion::push_turn_terminal_marker(
            agent,
            event,
            ending_prompt_id.as_deref(),
            false,
        );

        let notification = match (&result, was_cancelling) {
            (Ok(_), false) if !agent.bash_turn => {
                let body = match elapsed {
                    Some(d) => {
                        format!("Turn complete in {}.", crate::util::format_duration(d))
                    }
                    None => String::from("Turn complete."),
                };
                Some((NotificationEventKind::TurnComplete, body))
            }
            (Err(err), _)
                if !rate_limited
                    && !model_incompatible
                    && !reauth_prompted
                    && !context_overflow =>
            {
                Some((NotificationEventKind::AgentError, format!("Error: {err}")))
            }
            _ => None,
        };

        agent.mark_turn_finished();
        agent.activity_started_at = None;
        agent.last_activity = None;

        // Drain all queued permission requests — the turn is over,
        // so any pending permissions are stale. Send Cancelled to each.
        drain_permission_queue(agent);

        // Dismiss any active plan approval or review — the turn
        // that produced it has completed, so the state is stale.
        if let Some(mut pav) = agent.plan_approval_view.take() {
            pav.send_stale_cancel();
            agent.plan_next_comment_id = pav.next_comment_id;
            agent.prompt.restore(pav.stashed_prompt);
            agent.line_viewer = None;
        }

        agent.cancel_turn_view = None;
        agent.cancel_turn_buttons.clear();

        // After a bash-mode turn, scroll to bottom so the user sees
        // the command output, but keep focus on the prompt for
        // consistency with normal prompt behavior.
        let was_bash_turn = agent.bash_turn;
        if agent.bash_turn {
            agent.bash_turn = false;
            agent.scrollback.goto_bottom();
        }
        agent.cron_task_id = None;

        // TurnComplete suppressed when queue is non-empty (badge
        // fires only after the final queued turn); AgentError always fires.
        if let Some((kind, body)) = notification {
            // A stashed server-authoritative adoption means the next
            // turn is about to start, so treat the queue as non-empty
            // (suppress the TurnComplete notification / idle escapes),
            // mirroring the local non-empty-queue behavior.
            let queue_empty =
                agent.session.pending_prompts.is_empty() && pending_adoption.is_none();
            let session_name = agent
                .display_name
                .as_deref()
                .or(agent.generated_session_title.as_deref());

            // Skip idle escapes when queue is non-empty — the next
            // turn starts immediately and would overwrite them (title flicker).
            if queue_empty {
                let cwd_str = app.cwd.to_string_lossy();
                let model = agent.session.models.current_model_name();
                let idle_title = crate::notifications::TitleState {
                    session_name,
                    model: model.as_deref(),
                    activity: None,
                    has_pending_permissions: false,
                    cwd: Some(&cwd_str),
                    turn_elapsed: None,
                    is_busy: false,
                    focused: true,
                };
                app.pending_notification_escapes =
                    app.notification_service.build_idle_escapes(&idle_title);
            }

            if kind != NotificationEventKind::TurnComplete || queue_empty {
                // Defer the notification so the terminal has time
                // to apply the idle title.  Ghostty debounces
                // setTitle() by 75 ms (SurfaceView_AppKit.swift:576),
                // so we need >75 ms before the notification reads
                // self.title for the subtitle.  3 ticks × 33 ms ≈ 99 ms.
                let session_id = agent.session.session_id.as_ref().map(|s| s.0.to_string());

                // Use the session name as the notification title so
                // terminals that show it (Ghostty/OSC 777) display
                // which session completed.  For body-only protocols
                // (Warp, iTerm2/OSC 9), emit_notification folds the
                // title into the body automatically.
                let notif_title = session_name
                    .map(|s| s.to_string())
                    .unwrap_or_else(|| "Kigi".into());

                app.deferred_notification = Some((
                    NotificationEvent {
                        kind,
                        title: notif_title,
                        body,
                        session_id,
                    },
                    3,
                ));
            }
        }

        if let Err(ref err) = result {
            tracing::error!(agent = ?agent_id, error = %err, "Prompt failed");
        }

        // Predicted-next-prompt (tab autocomplete): wipe any stale suggestion
        // at every turn boundary. This must run before the reconnect /
        // paywall early returns below, which skip the fetch gate
        // entirely — a prior ghost would otherwise survive those paths.
        agent.prompt.prompt_suggestion.clear();

        // Cancelled turns resume queue processing one item at a time
        // through the same drain path as normal completions.
        // `maybe_drain_queue` keeps the idle-only and editing-front
        // guards so we do not send from under the user.
        if app.reconnect_pending {
            if let Some(p) = pending_adoption {
                agent.discard_pending_adoption_updates(&p.prompt_id);
            }
            return vec![];
        }

        // FIFO handoff: if a server-authoritative prompt drained
        // into the running slot during this turn's teardown, adopt it
        // now (finish_turn cleared current_prompt_id) and run the
        // turn-start shim. This sets `TurnRunning`, so the
        // `maybe_drain_queue` below no-ops rather than draining a local
        // prompt — the leader owns the drain order.
        if let Some(p) = pending_adoption
            && agent.session.current_prompt_id.is_none()
        {
            if response_pid.as_deref() != Some(p.prompt_id.as_str())
                && agent.should_adopt_running_prompt(&p.prompt_id)
            {
                apply_turn_start_shim(agent, p.prompt_id, p.text, &p.kind);
            } else {
                agent.discard_pending_adoption_updates(&p.prompt_id);
            }
        }

        let mut effects = maybe_drain_queue(agent);

        // Predicted-next-prompt (tab autocomplete): fetch a fresh suggestion
        // (the stale one was wiped above) — but only after a clean, non-bash
        // agent turn that leaves the session idle with an empty prompt and no
        // queued work, local or server-side (a draft in progress or a draining
        // queue means the user is already mid-thought). Placed after
        // `maybe_drain_queue` so `is_idle` reflects a locally-drained next
        // turn.
        if crate::views::prompt_suggestion::resolve_enabled()
            && result.is_ok()
            && !was_cancelling
            && !was_bash_turn
            && agent.prompt.text().is_empty()
            && agent.session.pending_prompts.is_empty()
            && agent.shared_queue.is_empty()
            && agent.session.state.is_idle()
            && let Some(session_id) = agent.session.session_id.as_ref().map(|s| s.0.to_string())
        {
            let generation = agent.prompt.prompt_suggestion.begin_fetch();
            let model = crate::views::prompt_suggestion::resolve_model(&agent.session.models);
            effects.push(Effect::FetchPromptSuggestion {
                agent_id,
                generation,
                model,
                session_id: Some(session_id),
            });
        }

        return effects;
    }
    vec![]
}

pub(super) fn handle_compact_complete(
    app: &mut AppView,
    agent_id: AgentId,
    result: Result<(), String>,
) -> Vec<Effect> {
    if let Some(agent) = app.agents.get_mut(&agent_id) {
        // Defensive: only process if we're still in CommandRunning state.
        // This guards against state machine bugs or future cancellation support.
        if !matches!(agent.session.state, AgentState::CommandRunning { .. }) {
            tracing::debug!("Ignoring CompactComplete (not in CommandRunning state)");
            return vec![];
        }

        let elapsed = agent.turn_elapsed();
        agent.session.finish_command();

        match &result {
            Ok(()) => {
                agent.scrollback.push_block(RenderBlock::session_event(
                    SessionEvent::CompactCompleted {
                        elapsed: elapsed.unwrap_or_default(),
                    },
                ));
            }
            Err(err) => {
                tracing::error!(agent = ?agent_id, error = %err, "Compaction failed");
                agent.scrollback.push_block(RenderBlock::session_event(
                    SessionEvent::CompactionFailed {
                        error: String::new(),
                    },
                ));
            }
        }

        agent.mark_turn_finished();
        agent.activity_started_at = None;
        agent.last_activity = None;

        if app.reconnect_pending {
            return vec![];
        }
        return maybe_drain_queue(agent);
    }
    vec![]
}

pub(super) fn handle_suggestion_debounce_expired(
    app: &mut AppView,
    agent_id: AgentId,
    generation: u64,
) -> Vec<Effect> {
    // Route by the arming agent (the timer carries it), not the active
    // view: a view switch inside the debounce window must neither fire a
    // spurious fetch on another agent nor drop this one's.
    let Some(agent) = app.agents.get(&agent_id) else {
        return vec![];
    };
    // Bash-mode feature: a debounce that outlives the mode fetches nothing.
    if agent.prompt_input_mode != crate::app::agent_view::PromptInputMode::Bash {
        return vec![];
    }
    if !agent.prompt.suggestions.on_debounce_expired(generation) {
        return vec![];
    }
    let text = agent.prompt.text().to_owned();
    let cursor = agent.prompt.cursor();
    let cwd = agent.session.cwd.to_string_lossy().into_owned();
    let include_ai = agent.prompt.suggestions.ai_enabled;
    let ai_model = agent.prompt.suggestions.ai_model.clone();
    let session_id = agent.session.session_id.as_ref().map(|s| s.0.to_string());
    vec![Effect::FetchShellSuggestions {
        agent_id,
        text,
        cursor,
        cwd,
        generation,
        limit: crate::views::suggestion_controller::SHELL_SUGGEST_WIRE_LIMIT,
        include_ai,
        ai_model,
        session_id,
        // The as-you-type (ghost) surface keeps history/AI providers.
        token_only: false,
    }]
}
