//! TUI application state.
//!
//! The render loop and the event loop are in [`super::events`]; this
//! module is the pure state machine they drive. Keeping it pure lets
//! the TestBackend smoke test and the inline unit tests assert
//! behaviour without spinning up tokio.

use std::cell::{Cell, RefCell};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};

use ratatui::layout::Rect;
use serde_json::Value;

use crate::daemon::host_metrics::HostMetricsSnapshot;
use crate::discovery::DiscoveredModel;
use crate::theme::{palette_for, Palette, ThemeName};
use crate::tui::filter::rank;
use crate::tui::keybindings::{Action, Focus, KeyMap};
use crate::tui::launch_picker::LaunchPickerState;
use crate::tui::list_pane::{build_rows, ListRow, RowInputs, RunningLaunchRow};
use crate::tui::status_icons::SurfaceState;
use crate::tui::tabs::{tabs_for_mode, RightTab};

/// Maximum age of a toast before the App auto-clears it. Keeps
/// transient yank confirmations from sticking around forever.
const TOAST_TTL: Duration = Duration::from_secs(3);

/// Body width (cells) at which the dashboard flips from wide-pane
/// to compact-pane mode. At ≥ 100 cells both panes render side by
/// side (current 65/35 layout). Below 100 cells the right pane is
/// "drill-in only" — hidden by default, visible whenever focus
/// lives inside it (see [`App::right_pane_visible_at`]).
pub const COMPACT_WIDTH_THRESHOLD: u16 = 100;

/// Map the daemon's wire-stable `gpu_backend` label
/// (`"nvidia"`/`"amd"`/...) to the recommender's per-backend key
/// (`"cuda"`/`"hip"`/...). Kept here so the dialog stays decoupled
/// from the daemon's sampler vocabulary. R113: `"unknown"` and
/// `"unsampled"` pass through verbatim so `vram_fit_for_file` can
/// return `FileFit::Unknown`.
fn recommender_backend_key(wire: &str) -> &'static str {
  use crate::daemon::host_metrics::HostMetricsSnapshot as H;
  match wire {
    H::BACKEND_NVIDIA => "cuda",
    H::BACKEND_AMD => "hip",
    H::BACKEND_APPLE_METAL => "metal",
    H::BACKEND_CPU_ONLY => "cpu",
    // Vulkan-only or never-sampled fall through; the dialog renders
    // `FileFit::Unknown` rather than fake confidence.
    H::BACKEND_UNKNOWN => "unknown",
    _ => "unknown",
  }
}

/// How many entries the `↺ Recent` section surfaces. Five matches
/// what the user picked during planning; the daemon's storage
/// itself isn't capped — the cap is purely a render-side window.
const RECENT_LIST_CAP: usize = 5;

/// In-memory snapshot of one launched model the daemon is
/// supervising. Mirrors the IPC `status` shape — kept in App so
/// the right-pane header can show port/state without re-querying.
#[derive(Debug, Clone)]
pub struct ManagedRow {
  pub launch_id: String,
  pub path: PathBuf,
  pub port: u16,
  pub state: SurfaceState,
  /// Latest per-PID RSS reading in bytes. `None` until the daemon's
  /// per-launch sampler has emitted at least one reading.
  pub rss_bytes: Option<u64>,
  /// Latest per-PID CPU usage percent (multi-core, may exceed 100%).
  /// `None` until the daemon's per-launch sampler primes.
  pub cpu_pct: Option<f32>,
}

/// Persisted "last successful launch params" for one model, fetched
/// via the daemon's `last_params_list` method. The TUI consults this
/// when opening the launch picker so the user lands on the same
/// ctx/reasoning/advanced they last shipped — without re-typing.
#[derive(Debug, Clone, Default)]
pub struct LastParamsRow {
  pub ctx: Option<u32>,
  pub reasoning: bool,
  /// User-supplied typed-knob deltas from the last successful launch
  /// (the *user's* contribution, not the resolved set). The editor
  /// seeds its `user_knobs` row directly from this so a returning
  /// user lands on the same overrides; rows the user never touched
  /// re-resolve from yaml / built-in / model default.
  pub knobs: crate::config::TypedKnobs,
  /// Free-form argv tail that landed on `--`. Surfaces back in the
  /// editor's `extras` row.
  pub extras: Vec<String>,
  /// Port the model was last successfully bound on. The picker
  /// passes this back as a soft preference (`prefer_port`) so a
  /// returning user lands on the same port if it's still free.
  pub port: Option<u16>,
}

/// Snapshot of the daemon-side metadata the Daemon info panel
/// renders. Mirrors the `daemon` object on the `status` response.
#[derive(Debug, Clone, Default)]
pub struct DaemonInfo {
  pub pid: Option<u32>,
  pub uptime_seconds: Option<u64>,
  pub build: Option<String>,
  pub server_path: Option<String>,
  /// HTTP control-plane URL the daemon bound on (e.g.
  /// `http://127.0.0.1:48134`). Rendered in the Daemon info panel
  /// alongside pid + uptime so an operator can see at a glance where
  /// the IPC channel is. `None` when the daemon hasn't surfaced the
  /// field (pre-Phase-A binaries don't).
  pub ipc_url: Option<String>,
  /// Latest snapshot of the OpenAI-compat proxy listener (Unit 5).
  /// `None` when talking to a pre-Unit-5 daemon that omits the
  /// field — info_pane renders the proxy row as `proxy   —` in that case.
  pub proxy: Option<ProxyInfo>,
}

/// Wire shape of the proxy listener block surfaced via the IPC
/// `status` response (R161). Parsed from the daemon's JSON and held
/// on `DaemonInfo` so [`crate::tui::info_pane`] can render a one-line
/// summary in the Daemon panel.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxyInfo {
  /// Mirrors config: `false` when `proxy.enabled: false`. Tracks
  /// whether the listener was *intended* to be up, separately from
  /// whether the bind succeeded.
  pub enabled: bool,
  /// Attempted bind address. `None` only when `enabled == false`.
  pub listen: Option<String>,
  /// One of `disabled` / `listening` / `port_in_use` / `unbound`.
  pub status: String,
  /// OS-level cause when `status == "unbound"`; `None` otherwise.
  pub bind_error: Option<String>,
}

/// Per-frame cache of the screen rectangles that mouse-focus needs
/// to hit-test against. Populated by [`crate::tui::render`] each
/// frame; consumed by the click handler in [`crate::tui::events`].
///
/// Lives in a `RefCell` because the render path takes `&App` (the
/// renderer is read-mostly and we keep the borrow non-mutable so the
/// per-frame memo helpers stay simple). The struct is dirt-cheap to
/// rewrite each frame — five `Rect`s + a small `Vec` — so there's no
/// staleness concern: the event loop only ever reads what the most
/// recent draw produced.
#[derive(Debug, Clone, Default)]
pub struct MouseHitRects {
  /// Models list pane (left half of the body, full-body when the
  /// right pane is hidden). Empty `Rect` until the first draw.
  pub list_pane: Rect,
  /// Right pane outer area (border included). Empty when the right
  /// pane is hidden for this frame.
  pub right_pane: Rect,
  /// Per-tab clickable spans inside the right pane's title strip.
  /// Each entry is the exact cell range for one label so the hit
  /// test is `mouse.x ∈ rect && mouse.y == rect.y`. Empty when the
  /// right pane is hidden.
  pub right_tabs: Vec<(RightTab, Rect)>,
}

/// Immutable parts of the App that don't change after construction.
#[derive(Debug, Clone)]
pub struct AppOptions {
  pub theme: ThemeName,
  /// User-defined palette resolved from `config.custom_theme`.
  /// `None` when the user didn't supply a custom theme block — in
  /// that case `ThemeName::Custom` falls through to macchiato via
  /// `palette_for`, and `cycle_theme` skips it.
  pub custom_palette: Option<Palette>,
  /// Runtime keybinding table. Built from defaults +
  /// `config.keybindings` overrides at startup; queried via
  /// `App::action_for` / `App::bindings_for`. Held in `AppOptions`
  /// (vs. directly on `App`) so it travels through the same
  /// construction path as the theme.
  pub keymap: KeyMap,
  /// Resolved offline mode for this TUI session. `true` when the
  /// user passed `--offline` on the CLI or `LLAMASTASH_OFFLINE=1`
  /// at startup. Threads into the HF dialog's FetchClient + the
  /// download dispatch so a pull confirmation can't trigger network
  /// I/O behind the user's back.
  pub offline: bool,
  /// Opt into terminal mouse capture (`mouse_focus: true` in
  /// `config.yaml`). When enabled, [`super::events::run`] turns on
  /// SGR mouse reporting so a left-click on the Models list or a
  /// right-pane tab label moves focus / switches tab. The default is
  /// off so the terminal keeps native click-and-drag text selection
  /// — see the long comment in [`super::events::run`] for the trade.
  pub mouse_focus: bool,
}

impl Default for AppOptions {
  fn default() -> Self {
    Self {
      theme: ThemeName::Macchiato,
      custom_palette: None,
      keymap: KeyMap::default(),
      offline: false,
      mouse_focus: false,
    }
  }
}

/// Central App state.
#[derive(Debug)]
pub struct App {
  pub options: AppOptions,
  pub focus: Focus,
  pub models: Vec<DiscoveredModel>,
  pub favorites: Vec<PathBuf>,
  pub managed: Vec<ManagedRow>,
  /// External (unmanaged) `llama-server` processes the daemon's
  /// sweep surfaced. Read-only — the TUI shows them with the `⇪`
  /// glyph; only `stop` is permitted on these rows.
  pub external: Vec<ManagedRow>,
  /// Last-known persisted launch params per model path. Keyed off
  /// the canonical `ModelId.path` the daemon emits.
  pub last_params: BTreeMap<PathBuf, LastParamsRow>,
  /// Top-N recently-launched paths in recency order (most recent
  /// first). Surfaced via the `↺ Recent` section. Populated from
  /// `last_params_list`; see `RECENT_LIST_CAP`.
  pub recent_paths: Vec<PathBuf>,
  /// Selected right-pane tab. `Settings` is always reachable;
  /// `Logs` plus the mode-specific tab (`Chat` / `Embed` /
  /// `Rerank`) become reachable when the focused model is running.
  pub right_tab: RightTab,
  /// Working chat session for the right-pane Chat tab. Holds the
  /// in-progress prompt and the most recent response so the render
  /// path is purely synchronous.
  pub chat: crate::tui::tabs::chat::ChatTabState,
  /// Embed-tab working state — single text input and latest
  /// response payload.
  pub embed: crate::tui::tabs::embed::EmbedTabState,
  /// Rerank-tab working state — query + candidate list.
  pub rerank: crate::tui::tabs::rerank::RerankTabState,
  /// Logs-tab buffer for the focused launch. Refreshed from the
  /// daemon's `logs_tail` IPC method on each tick.
  pub logs_state: crate::tui::tabs::logs::LogsTabState,
  /// Cursor index into the rendered row list (which mixes headers
  /// and models). Header rows are skipped during `move_*`.
  pub list_cursor: usize,
  /// Filter input — modal text field backed by [`crate::tui::input_field::InputField`] so the
  /// editing semantics (`e` enters edit, `Esc` walks back exit-edit
  /// → clear → close) match every other text input. Filter auto-
  /// enters edit on `open_filter` so the user can type immediately.
  pub filter_input: crate::tui::input_field::InputField,
  pub launch_picker: Option<LaunchPickerState>,
  /// Vertical scroll offset for the Settings tab's read-only
  /// running-launch view. The view shows ~17 rows (launch id, 14
  /// typed knobs, extras, footer); on a short viewport the user
  /// scrolls with ↑/↓. Resets on model-list nav and when the picker
  /// opens or closes.
  ///
  /// `Cell` so the render path (which holds `&App`) can write back
  /// the clamped value when the stored offset drifts past
  /// `max_scroll` — without writeback, holding ↓ past the bottom
  /// would inflate the stored offset and the next ↑ press would
  /// appear to do nothing until the value dropped back below
  /// `max_scroll`. Same pattern as
  /// [`LaunchPickerState::scroll_offset`].
  pub running_view_scroll: Cell<u16>,
  pub toast: Option<(String, Instant)>,
  pub daemon_connected: bool,
  /// Snapshot of the daemon-side metadata block from the most recent
  /// `status` response. Populated by [`Self::ingest_status`].
  pub daemon_info: DaemonInfo,
  /// Latest host-level CPU/RAM/GPU readings the daemon's sampler
  /// emits. Populated by [`Self::ingest_status`]; consumed by the
  /// Host info-row pane.
  pub host_metrics: HostMetricsSnapshot,
  /// Set when the user presses `q` so the event loop can exit.
  pub should_exit: bool,
  /// Whether the modal help overlay is visible. Bound to `?`.
  pub show_help: bool,
  /// Vertical scroll offset for the help overlay (in lines). Lets the
  /// overlay survive on terminals too short to fit every category.
  /// Reset to `0` whenever the overlay closes; advanced by `j`/`k`,
  /// arrow keys, and PgUp/PgDn while it's open.
  pub help_scroll: u16,
  /// True after `g` was pressed in the right pane and we're awaiting
  /// the second half of a vim-style `gt` / `gT` chord. Cleared on the
  /// next keystroke regardless of whether the chord completed — one-
  /// shot, no timeout.
  pub pending_g_prefix: bool,
  /// Modal "are you sure?" prompt. `Some(...)` shows a centred
  /// confirmation overlay that captures `y` / Enter to dispatch
  /// the inner action and `n` / Esc to dismiss. Used by stop-model
  /// and kill-daemon so a fat-finger doesn't drop a running model
  /// or the whole supervisor.
  pub confirm_dialog: Option<ConfirmAction>,
  /// HuggingFace pull dialog (R104). `Some(_)` whenever the modal
  /// is open; the input pump routes through `Focus::HfDialog` to the
  /// per-stage key handler.
  pub hf_dialog: Option<crate::tui::hf_dialog::HfDialogState>,
  /// Pinned download status strip (R115). Always present; the
  /// renderer reserves a 1-line slot above the body only when
  /// `download_strip.is_active()` is true.
  pub download_strip: crate::tui::download_strip::DownloadStripState,
  /// Per-frame memo of `rendered_rows()`. Primed at the top of
  /// `render::render` and cleared at the bottom — see audit §4.1
  /// #1 (the biggest single perf finding). The same `Vec<ListRow>`
  /// used to be rebuilt 5+ times per frame via `focused_path`,
  /// `focused_managed`, `focused_name`, and the right-pane render
  /// helpers. None outside a frame so event handlers always see
  /// fresh state.
  pub(crate) rows_cache: Option<Vec<ListRow>>,
  /// Per-frame memo of `available_right_tabs()`. Three calls per
  /// frame used to walk `models` linearly + allocate a fresh
  /// `Vec<RightTab>` each time (audit §F4.1 #2). Same lifetime
  /// rules as `rows_cache`.
  pub(crate) right_tabs_cache: Option<Vec<RightTab>>,
  /// Hit-test rectangles refreshed every frame by the renderer.
  /// Read by the mouse-click dispatch in [`crate::tui::events`] so a
  /// `MouseEventKind::Down(Left)` can resolve `(column, row)` to a
  /// focus / right-tab without the event handler re-computing the
  /// layout. Only meaningful when `options.mouse_focus` is true; left
  /// at its default empty state otherwise.
  pub(crate) hit_rects: RefCell<MouseHitRects>,
  /// Sender into the unified TUI event channel. `Some(_)` during a
  /// real `run()` session; `None` in unit tests that drive `pump_input`
  /// directly without spinning a runtime. Subsystems (chat stream,
  /// embed/rerank, HF dialog, download tasks) clone this when
  /// spawning a tokio task so their results land back on the same
  /// `recv` the main loop blocks on.
  pub events_tx: Option<tokio::sync::mpsc::Sender<crate::tui::events::Event>>,
  /// Previous observed `proxy.status` label. Used by
  /// [`Self::ingest_status`] to fire a one-shot toast on the
  /// transition into `port_in_use` (plan §Approach: "flag on first
  /// observation"). Reading the cell on every tick would surface a
  /// toast each refresh; transitions keep the noise to once-per-
  /// session-collision.
  pub(crate) last_proxy_status: Option<String>,
}

/// Action awaiting user confirmation in the modal popup. Captured
/// at the moment the user presses the trigger key, applied when
/// they confirm with `y` / Enter.
#[derive(Debug, Clone)]
pub enum ConfirmAction {
  /// `s:stop` — graceful shutdown of one managed launch.
  StopModel { launch_id: String, name: String },
  /// `Q:kill daemon` — issues a `shutdown` RPC to the daemon.
  KillDaemon,
  /// `Ctrl+R:restart daemon` — shuts the daemon down and re-spawns
  /// a fresh one. All managed launches are stopped in the process.
  RestartDaemon,
  /// `Ctrl+D:delete` — remove a non-running model from disk.
  /// `path` is the GGUF (or split-shard launch file); the deleter
  /// walks the HF snapshot dir up to the cache root when the file
  /// is symlinked into `~/.cache/huggingface`, so a confirmed
  /// delete reclaims the blob bytes too.
  DeleteModel { path: PathBuf, display_name: String },
  /// `Ctrl+X:cancel download` — abort the currently-active HF
  /// download. The queue stays intact; the next queued pull is
  /// promoted on confirm. `friendly_name` is what the popup renders
  /// so the user reads the same identifier the strip is showing.
  CancelDownload {
    repo_id: String,
    friendly_name: String,
  },
  /// `Enter:launch` on a model that already has a managed launch
  /// (round-8). v1 supports duplicate launches on fresh ports, but
  /// we ask the user to confirm so a stray Enter doesn't silently
  /// spin up another instance. The payload mirrors
  /// `WriterCmd::StartModel` — captured at popup time so the
  /// launch dispatches even if focus moves while the popup is up.
  LaunchDuplicate {
    /// Display name for the popup body.
    name: String,
    /// Existing managed instance count, surfaced in the popup
    /// body so the user understands what they're piling on top of.
    active_instances: usize,
    model_path: PathBuf,
    ctx: Option<u32>,
    reasoning: Option<bool>,
    knobs: crate::config::TypedKnobs,
    extras: Vec<String>,
    mode: Option<crate::launch::mode::LaunchMode>,
    prefer_port: Option<u16>,
  },
}

impl App {
  pub fn new(options: AppOptions) -> Self {
    Self {
      options,
      focus: Focus::List,
      models: Vec::new(),
      favorites: Vec::new(),
      managed: Vec::new(),
      external: Vec::new(),
      last_params: BTreeMap::new(),
      recent_paths: Vec::new(),
      right_tab: RightTab::Settings,
      chat: Default::default(),
      embed: Default::default(),
      rerank: Default::default(),
      logs_state: Default::default(),
      list_cursor: 0,
      filter_input: crate::tui::input_field::InputField::new(),
      launch_picker: None,
      running_view_scroll: Cell::new(0),
      toast: None,
      daemon_connected: false,
      daemon_info: DaemonInfo::default(),
      host_metrics: HostMetricsSnapshot::default(),
      should_exit: false,
      show_help: false,
      help_scroll: 0,
      pending_g_prefix: false,
      confirm_dialog: None,
      hf_dialog: None,
      download_strip: crate::tui::download_strip::DownloadStripState::default(),
      rows_cache: None,
      right_tabs_cache: None,
      hit_rects: RefCell::new(MouseHitRects::default()),
      events_tx: None,
      last_proxy_status: None,
    }
  }

  /// `true` when the download strip should be rendered. Delegates
  /// to [`crate::tui::download_strip::DownloadStripState`] so the renderer's layout
  /// decision and the strip's render contract stay aligned.
  pub fn download_strip_active(&self) -> bool {
    self.download_strip.is_active()
  }

  /// Open the HuggingFace pull dialog. Initialises in the Search
  /// stage and snaps focus into [`Focus::HfDialog`] so the per-stage
  /// key router takes over. The dialog reads its offline flag from
  /// [`AppOptions::offline`] (which is itself the runtime-resolved
  /// `--offline` ∨ `LLAMASTASH_OFFLINE` value) so the "search
  /// disabled" hint renders immediately and the dialog's spawned
  /// fetch tasks short-circuit before any HF traffic.
  pub fn open_hf_dialog(&mut self) {
    if self.hf_dialog.is_none() {
      let ctx = self.hf_hardware_fit_ctx();
      let offline = self.options.offline || crate::init::fetch::offline_requested(false);
      self.hf_dialog = Some(crate::tui::hf_dialog::HfDialogState::open(offline, ctx));
    }
    self.focus = Focus::HfDialog;
  }

  /// Snapshot the inputs `vram_fit_for_file` needs into the dialog
  /// state at open time. Backend / VRAM / RAM come from the
  /// daemon's host-metrics sampler; the per-backend overhead band
  /// is read from the bundled benchmark snapshot. R111 + R113.
  fn hf_hardware_fit_ctx(&self) -> crate::tui::hf_dialog::HardwareFitContext {
    use crate::tui::hf_dialog::HardwareFitContext;
    let backend = recommender_backend_key(&self.host_metrics.gpu_backend);
    let vram_bytes = self.host_metrics.gpu_mem_total_bytes;
    let ram_total_bytes = self.host_metrics.ram_total_bytes;
    let overhead_band_bytes = crate::init::benchmark::load_bundled()
      .recommender_weights
      .overhead_band_bytes
      .get(backend)
      .copied();
    HardwareFitContext {
      backend: backend.to_string(),
      vram_bytes,
      ram_total_bytes,
      overhead_band_bytes,
      ctx_tokens: crate::init::recommender::DEFAULT_CTX,
    }
  }

  /// Close the HuggingFace pull dialog and snap focus back to the
  /// Models list. Background download tasks the dialog spawned
  /// (Unit 6) keep ticking under the pinned strip — closing the
  /// dialog does not cancel them.
  pub fn close_hf_dialog(&mut self) {
    self.hf_dialog = None;
    self.focus = Focus::List;
  }

  /// Memoize `rendered_rows()` and `available_right_tabs()` for the
  /// duration of one frame so the 12+ in-frame `rendered_rows()`
  /// calls and 3+ in-frame `available_right_tabs()` calls amortise
  /// to a single build each. Paired with
  /// [`Self::clear_frame_caches`]. See audit §4.1 #1 and §F4.1 #2.
  pub(crate) fn prime_frame_caches(&mut self) {
    self.rows_cache = Some(self.rendered_rows_uncached());
    self.right_tabs_cache = Some(self.available_right_tabs_uncached());
  }

  /// Drop the per-frame memos. They must not outlive a frame —
  /// event handlers between frames mutate models / managed /
  /// favorites freely and would observe stale state.
  pub(crate) fn clear_frame_caches(&mut self) {
    self.rows_cache = None;
    self.right_tabs_cache = None;
  }

  /// Back-compat shim; new code should call `prime_frame_caches`.
  #[doc(hidden)]
  pub(crate) fn prime_rows_cache(&mut self) {
    self.prime_frame_caches();
  }

  /// Back-compat shim; new code should call `clear_frame_caches`.
  #[doc(hidden)]
  pub(crate) fn clear_rows_cache(&mut self) {
    self.clear_frame_caches();
  }

  /// True when the right pane should render at the supplied body
  /// width.
  ///
  /// **Wide** (`body_width >= COMPACT_WIDTH_THRESHOLD`): pane is
  /// always visible as long as the user has at least one discovered
  /// model — it follows the cursor and surfaces Settings (and
  /// Logs/Chat when running) for the focused row.
  ///
  /// **Compact** (`body_width < COMPACT_WIDTH_THRESHOLD`): pane is
  /// "drill-in only" — hidden by default, opens when focus moves
  /// into it (`open_launch_picker` from `Enter` on a model row, or
  /// any focus chord that lands in the right pane). `Esc` /
  /// `close_launch_picker` moves focus back to `List` and the pane
  /// disappears, expanding the list back to full width.
  ///
  /// Focus is the single source of truth in compact mode — no
  /// separate flag — so every existing path that moves focus
  /// (Cancel, FocusList, close_launch_picker) already implicitly
  /// closes the pane.
  pub fn right_pane_visible_at(&self, body_width: u16) -> bool {
    if self.models.is_empty() {
      return false;
    }
    body_width >= COMPACT_WIDTH_THRESHOLD || self.right_pane_focused()
  }

  /// True when focus currently lives inside the right pane (or one
  /// of its tab-bound input fields). Used by `right_pane_visible_at`
  /// to keep the pane open in compact mode whenever the user has
  /// drilled in. Mirrors the predicate in `render::right_is_focused`
  /// — keep them in sync.
  pub fn right_pane_focused(&self) -> bool {
    matches!(
      self.focus,
      Focus::RightPane | Focus::ChatInput | Focus::EmbedInput | Focus::RerankInput
    )
  }

  /// Toggle the modal help overlay. Bound to `?`. Esc also closes
  /// it via the existing Cancel action plumbing.
  pub fn toggle_help(&mut self) {
    self.show_help = !self.show_help;
    self.help_scroll = 0;
  }

  /// Resolve the active palette. For `ThemeName::Custom`, prefer the
  /// palette loaded from `config.custom_theme`; fall back to the
  /// built-in (macchiato) if `Custom` was selected without a loaded
  /// palette. Returns a borrow tied to `&self` because the custom
  /// palette lives on `options`, not in a static slot.
  pub fn palette(&self) -> &Palette {
    if self.options.theme == ThemeName::Custom {
      if let Some(custom) = &self.options.custom_palette {
        return custom;
      }
    }
    palette_for(self.options.theme)
  }

  /// Resolve a `(focus, key, mods)` triple through the live keymap.
  /// Drop-in replacement for the legacy `keybindings::action_for`
  /// free function so renderers/events can pick up
  /// `config.keybindings` overrides without re-implementing the
  /// dispatcher.
  pub fn action_for(
    &self,
    focus: Focus,
    key: crossterm::event::KeyCode,
    mods: crossterm::event::KeyModifiers,
  ) -> Option<Action> {
    self.options.keymap.action_for(focus, key, mods)
  }

  /// Bindings the help overlay should show for `focus`. Pulls from
  /// the runtime keymap so overrides applied at startup surface in
  /// the modal help screen too.
  pub fn bindings_for(&self, focus: Focus) -> &[crate::tui::keybindings::Binding] {
    self.options.keymap.bindings_for(focus)
  }

  /// Build a `key:hint` chip string for `(focus, action)` against the
  /// live keymap. Returns `None` when the user has unbound the
  /// action in `focus` entirely so callers can drop the hint rather
  /// than render a chip with no working key.
  ///
  /// The chip text comes from `Action::hint_for(focus)` with a
  /// fallback to the binding's `hint` field, so updates in
  /// `keybindings.rs` flow through to every chip. For one-off
  /// caller-supplied overrides see [`Self::hint_with`].
  pub fn hint(&self, focus: Focus, action: Action) -> Option<String> {
    let b = self
      .bindings_for(focus)
      .iter()
      .find(|b| b.action == action)?;
    let hint = action.hint_for(focus).unwrap_or(b.hint);
    Some(format!("{}:{}", b.label, hint))
  }

  /// Like [`Self::hint`] but with a caller-supplied description
  /// override. Same `None`-on-unbound semantics so the hint strip
  /// stays honest about what keys actually work.
  pub fn hint_with(&self, focus: Focus, action: Action, description: &str) -> Option<String> {
    let b = self
      .bindings_for(focus)
      .iter()
      .find(|b| b.action == action)?;
    Some(format!("{}:{}", b.label, description))
  }

  /// Resolve the live label (`Esc`, `Enter`, `Ctrl+X`, …) for
  /// `(focus, action)`, falling back to `fallback` when the action
  /// is unbound. Used by dialog renderers that want a single-token
  /// chord label (without a description) so `format!("{label}
  /// returns to ...")` reads naturally. Both the HF dialog footer
  /// and the confirm-popup body call this; the helper lives on
  /// `App` so we have one canonical lookup.
  pub fn resolve_label(&self, focus: Focus, action: Action, fallback: &str) -> String {
    self
      .bindings_for(focus)
      .iter()
      .find(|b| b.action == action)
      .map(|b| b.label.to_string())
      .unwrap_or_else(|| fallback.to_string())
  }

  /// Apply a `list_models` IPC response. The TUI calls this after
  /// every refresh.
  pub fn ingest_list_models(&mut self, body: &Value) {
    let arr = match body.get("models").and_then(Value::as_array) {
      Some(a) => a,
      None => return,
    };
    let mut next: Vec<DiscoveredModel> = Vec::with_capacity(arr.len());
    for row in arr {
      if let Some(m) = parse_list_models_row(row) {
        next.push(m);
      }
    }
    self.models = next;
    self.clamp_cursor();
  }

  /// Apply a `status` IPC response. Refreshes the supervisor's
  /// per-launch rows, the read-only external rows, the daemon-info
  /// block, and the host-metrics snapshot. Discovery rows survive
  /// intact — `list_models` owns those.
  pub fn ingest_status(&mut self, body: &Value) {
    // Both `models` and `external` clear when their field is absent.
    // Asymmetry would let ghost supervised rows persist across a
    // schema change or transient framing error, so the TUI would
    // continue offering a stop affordance for a launch the daemon no
    // longer tracks.
    if let Some(arr) = body.get("models").and_then(Value::as_array) {
      let next: Vec<ManagedRow> = arr.iter().filter_map(parse_status_row).collect();
      let prev_ids: std::collections::BTreeSet<String> =
        self.managed.iter().map(|m| m.launch_id.clone()).collect();
      // Detect transitions into `Error` so we can auto-jump the
      // right pane to Logs — the user explicitly wants to see the
      // failure tail, not the static Settings form. Keyed by
      // launch_id: each launch carries the same id across state
      // transitions, so a managed row going Running → Error is
      // matched on the same key.
      let prev_state_by_id: BTreeMap<String, SurfaceState> = self
        .managed
        .iter()
        .map(|m| (m.launch_id.clone(), m.state))
        .collect();
      let newly_errored: Vec<String> = next
        .iter()
        .filter(|m| m.state == SurfaceState::Error)
        .filter(|m| {
          prev_state_by_id
            .get(&m.launch_id)
            .map(|prev| *prev != SurfaceState::Error)
            .unwrap_or(true)
        })
        .map(|m| m.launch_id.clone())
        .collect();
      // Merge while preserving recency order:
      //   1. Launches that are new in this tick land at the top
      //      (newest first per the daemon's emission order).
      //   2. Launches that were already present keep their prior
      //      relative position.
      //   3. Launches that vanished (stopped) drop out.
      let next_by_id: BTreeMap<String, ManagedRow> = next
        .iter()
        .map(|m| (m.launch_id.clone(), m.clone()))
        .collect();
      let mut merged: Vec<ManagedRow> = Vec::with_capacity(next.len());
      let mut newest: Option<String> = None;
      for row in &next {
        if !prev_ids.contains(&row.launch_id) {
          if newest.is_none() {
            newest = Some(row.launch_id.clone());
          }
          merged.push(row.clone());
        }
      }
      for prev in &self.managed {
        if let Some(updated) = next_by_id.get(&prev.launch_id) {
          merged.push(updated.clone());
        }
      }
      self.managed = merged;
      // Snap the cursor onto the newest running launch so the user
      // sees their just-launched model selected. Only fires when a
      // genuinely new launch_id appeared on this tick.
      if let Some(launch_id) = newest {
        self.snap_cursor_to_launch(&launch_id);
      }
      // If the focused launch just transitioned into Error, snap
      // the right pane to Logs so the user sees the failure tail
      // without an extra Tab keystroke. Other launches in the
      // newly_errored set get picked up the moment the user focuses
      // them — `ensure_right_tab_reachable` keeps Logs reachable
      // for Error rows.
      if !newly_errored.is_empty() {
        if let Some(focused) = self.focused_managed() {
          if newly_errored.contains(&focused.launch_id) {
            self.right_tab = RightTab::Logs;
          }
        }
      }
    } else {
      self.managed.clear();
    }
    if let Some(arr) = body.get("external").and_then(Value::as_array) {
      self.external = arr.iter().filter_map(parse_external_row).collect();
    } else {
      self.external.clear();
    }
    if let Some(daemon) = body.get("daemon") {
      // Preserve the proxy snapshot — it lives in a sibling field
      // (`body["proxy"]`) but on the same daemon-info struct so the
      // info_pane can render both in one block.
      let proxy = body.get("proxy").and_then(parse_proxy_info);
      self.daemon_info = DaemonInfo {
        pid: daemon.get("pid").and_then(Value::as_u64).map(|n| n as u32),
        uptime_seconds: daemon.get("uptime_seconds").and_then(Value::as_u64),
        build: daemon
          .get("build")
          .and_then(Value::as_str)
          .map(String::from),
        server_path: daemon
          .get("server_path")
          .and_then(Value::as_str)
          .map(String::from),
        ipc_url: daemon
          .get("ipc_url")
          .and_then(Value::as_str)
          .map(String::from),
        proxy,
      };
      // First-observation toast on `port_in_use`. Only fires when the
      // last *observed* status was something other than `port_in_use`
      // (the daemon emits the same status on every tick, so reading
      // the cell value alone would re-toast forever). Snapshot the
      // fields we need out of the borrow before `show_toast` claims
      // a mutable borrow on `self`.
      let (cur_status, cur_listen): (Option<String>, Option<String>) =
        match self.daemon_info.proxy.as_ref() {
          Some(p) => (Some(p.status.clone()), p.listen.clone()),
          None => (None, None),
        };
      if let Some(status) = cur_status {
        let was_port_in_use = matches!(self.last_proxy_status.as_deref(), Some("port_in_use"));
        if status == "port_in_use" && !was_port_in_use {
          let listen = cur_listen.as_deref().unwrap_or("?");
          self.show_toast(format!(
            "proxy: port {listen} already in use; daemon continues without the OpenAI-compat router"
          ));
        }
        self.last_proxy_status = Some(status);
      }
    }
    if let Some(host) = body.get("host") {
      if !host.is_null() {
        if let Ok(snap) = serde_json::from_value::<HostMetricsSnapshot>(host.clone()) {
          self.host_metrics = snap;
        }
      }
    }
  }

  /// Apply a `last_params_list` IPC response. The TUI uses the
  /// snapshot to seed the launch picker for the focused model.
  pub fn ingest_last_params(&mut self, body: &Value) {
    let arr = match body.get("last_params").and_then(Value::as_array) {
      Some(a) => a,
      None => return,
    };
    // Track the IPC response order separately — the daemon emits
    // `last_params` newest-first now (see
    // `state_store::upsert_last_params`), so we use that order to
    // populate `recent_paths` for the `↺ Recent` section.
    let mut next: BTreeMap<PathBuf, LastParamsRow> = BTreeMap::new();
    let mut recent: Vec<PathBuf> = Vec::with_capacity(RECENT_LIST_CAP);
    for row in arr {
      let path = row
        .get("model_path")
        .and_then(Value::as_str)
        .map(PathBuf::from);
      let params = row.get("params");
      if let (Some(path), Some(params)) = (path, params) {
        let ctx = params.get("ctx").and_then(Value::as_u64).map(|n| n as u32);
        let reasoning = params
          .get("reasoning")
          .and_then(Value::as_bool)
          .unwrap_or(false);
        let knobs = params
          .get("knobs")
          .and_then(|v| serde_json::from_value(v.clone()).ok())
          .unwrap_or_default();
        let extras = params
          .get("extras")
          .and_then(Value::as_array)
          .map(|items| {
            items
              .iter()
              .filter_map(|v| v.as_str().map(String::from))
              .collect()
          })
          .unwrap_or_default();
        let port = params
          .get("port")
          .and_then(Value::as_u64)
          .and_then(|n| u16::try_from(n).ok());
        if recent.len() < RECENT_LIST_CAP {
          recent.push(path.clone());
        }
        next.insert(
          path,
          LastParamsRow {
            ctx,
            reasoning,
            knobs,
            extras,
            port,
          },
        );
      }
    }
    self.last_params = next;
    self.recent_paths = recent;
  }

  /// Apply a `favorite_list` IPC response.
  pub fn ingest_favorites(&mut self, body: &Value) {
    let arr = match body.get("favorites").and_then(Value::as_array) {
      Some(a) => a,
      None => return,
    };
    self.favorites = arr
      .iter()
      .filter_map(|row| {
        row
          .get("id")
          .and_then(|id| id.get("path"))
          .and_then(Value::as_str)
      })
      .map(PathBuf::from)
      .collect();
  }

  /// Build the list of rows the renderer should draw, applying any
  /// active filter. Cached results aren't worth it: discovery
  /// snapshots are small (hundreds of rows) and the filter is
  /// hand-rolled subsequence matching.
  pub fn rendered_rows(&self) -> Vec<ListRow> {
    if let Some(cached) = self.rows_cache.as_ref() {
      return cached.clone();
    }
    self.rendered_rows_uncached()
  }

  /// The expensive `build_rows + apply_filter` walk. Public only
  /// for the cache primer; every other caller goes through
  /// [`Self::rendered_rows`] so the per-frame memo applies.
  fn rendered_rows_uncached(&self) -> Vec<ListRow> {
    let model_states = self.surface_states();
    let model_ports = self.surface_ports();
    let running: Vec<RunningLaunchRow> = self
      .managed
      .iter()
      .map(|m| RunningLaunchRow {
        launch_id: m.launch_id.clone(),
        path: m.path.clone(),
        port: m.port,
        state: m.state,
      })
      .collect();
    let mut all = build_rows(RowInputs {
      models: &self.models,
      favorites: &self.favorites,
      model_states: &model_states,
      model_ports: &model_ports,
      running: &running,
      recent_paths: &self.recent_paths,
    });
    if !self.filter_input.is_empty() {
      all = apply_filter(&all, self.filter_input.buffer());
    }
    all
  }

  /// Move `list_cursor` onto the row whose `launch_id` matches.
  /// Used by `ingest_status` to land the user on a just-spawned
  /// launch so they don't have to chase it manually. No-op when
  /// the row isn't found (the merge may have ordered things
  /// differently on this tick).
  ///
  /// Routes through `sync_picker_to_focus` so any picker staged
  /// for the prior focused path is cleared when the snap lands on
  /// a different model — otherwise Settings would keep painting
  /// the stale picker's name/port for the just-snapped row.
  fn snap_cursor_to_launch(&mut self, launch_id: &str) {
    let rows = self.rendered_rows();
    if let Some((idx, _)) = rows.iter().enumerate().find(|(_, r)| match r {
      ListRow::Model {
        launch_id: Some(id),
        ..
      } => id == launch_id,
      _ => false,
    }) {
      let before = self.focused_path();
      self.list_cursor = idx;
      self.sync_picker_to_focus(before);
    }
  }

  fn surface_states(&self) -> BTreeMap<PathBuf, SurfaceState> {
    // Managed rows win over external when both reference the same
    // path — the daemon's sweep excludes adopted PIDs, so this
    // collision is rare in practice. External rows surface paths
    // that aren't part of the discovered catalog, so they're
    // skipped here and rendered separately (see
    // `rendered_rows`).
    let mut out: BTreeMap<PathBuf, SurfaceState> = BTreeMap::new();
    for m in &self.external {
      out.insert(m.path.clone(), m.state);
    }
    for m in &self.managed {
      out.insert(m.path.clone(), m.state);
    }
    out
  }

  /// Companion to [`Self::surface_states`] — collapses every active
  /// launch the daemon knows about into one `path → port` map for
  /// the Port column. The list pane is currently one row per
  /// discovered path, so the duplicate-launch case (which lives in
  /// the Running group after the next polish round) deliberately
  /// keeps just the last entry seen; the table cell still resolves
  /// to a useful port, just not a uniqueness-aware one.
  fn surface_ports(&self) -> BTreeMap<PathBuf, u16> {
    let mut out: BTreeMap<PathBuf, u16> = BTreeMap::new();
    for m in &self.external {
      out.insert(m.path.clone(), m.port);
    }
    for m in &self.managed {
      out.insert(m.path.clone(), m.port);
    }
    out
  }

  /// Rows for external `llama-server` processes the daemon detected
  /// outside its supervisor. Surfaced read-only (stop is the only
  /// action allowed) — used by Unit 7's right pane to show "this
  /// model is unmanaged" hints.
  pub fn external_rows(&self) -> &[ManagedRow] {
    &self.external
  }

  /// Move cursor down to the next selectable (model) row.
  pub fn move_down(&mut self) {
    let rows = self.rendered_rows();
    let before = self.focused_path();
    self.move_down_in(&rows);
    self.sync_picker_to_focus(before);
  }

  pub fn move_up(&mut self) {
    let rows = self.rendered_rows();
    let before = self.focused_path();
    self.move_up_in(&rows);
    self.sync_picker_to_focus(before);
  }

  /// Clear `launch_picker` when the cursor moved onto a different
  /// path. Round-8: the right pane follows the cursor with no
  /// sticky fallback — letting a picker staged for model A linger
  /// while the user scrolls to model B would render the wrong
  /// model name in the Settings tab. Caller passes the focused
  /// path *before* the move so we can compare against the path
  /// *after*.
  fn sync_picker_to_focus(&mut self, before: Option<PathBuf>) {
    let after = self.focused_path();
    if before != after {
      // Scroll offset is per-focused-model; clear it on cursor moves
      // so the new model's running-launch view opens at the top.
      self.running_view_scroll.set(0);
    }
    if self.launch_picker.is_none() {
      return;
    }
    if before != after {
      self.launch_picker = None;
    }
  }

  fn move_down_in(&mut self, rows: &[ListRow]) {
    if rows.is_empty() {
      return;
    }
    let mut next = self.list_cursor + 1;
    while next < rows.len() && !rows[next].is_selectable() {
      next += 1;
    }
    if next < rows.len() {
      self.list_cursor = next;
    }
  }

  fn move_up_in(&mut self, rows: &[ListRow]) {
    if self.list_cursor == 0 {
      return;
    }
    let mut next = self.list_cursor;
    while next > 0 {
      next -= 1;
      if rows.get(next).map(|r| r.is_selectable()).unwrap_or(false) {
        self.list_cursor = next;
        return;
      }
    }
  }

  /// Page-step: move the cursor by `delta` selectable rows. Positive
  /// values go down, negative up. Builds the rendered row vec once
  /// rather than once per step. Use this for PageUp/PageDown so a
  /// single keypress doesn't rebuild rows 10×.
  pub fn move_by(&mut self, delta: i32) {
    let rows = self.rendered_rows();
    let before = self.focused_path();
    if delta >= 0 {
      for _ in 0..delta {
        self.move_down_in(&rows);
      }
    } else {
      for _ in 0..-delta {
        self.move_up_in(&rows);
      }
    }
    self.sync_picker_to_focus(before);
  }

  pub fn go_top(&mut self) {
    let rows = self.rendered_rows();
    let before = self.focused_path();
    for (i, r) in rows.iter().enumerate() {
      if r.is_selectable() {
        self.list_cursor = i;
        break;
      }
    }
    self.sync_picker_to_focus(before);
  }

  pub fn go_bottom(&mut self) {
    let rows = self.rendered_rows();
    let before = self.focused_path();
    for (i, r) in rows.iter().enumerate().rev() {
      if r.is_selectable() {
        self.list_cursor = i;
        break;
      }
    }
    self.sync_picker_to_focus(before);
  }

  fn clamp_cursor(&mut self) {
    let rows = self.rendered_rows();
    if rows.is_empty() {
      self.list_cursor = 0;
      return;
    }
    if self.list_cursor >= rows.len() {
      self.list_cursor = rows.len() - 1;
    }
    if !rows[self.list_cursor].is_selectable() {
      // Snap to next selectable row.
      self.go_top();
    }
  }

  /// Path of the model the cursor sits on.
  pub fn focused_path(&self) -> Option<PathBuf> {
    let rows = self.rendered_rows();
    rows
      .get(self.list_cursor)
      .and_then(|r| r.path().map(|p| p.to_path_buf()))
  }

  /// Friendly display label for `path` if the discovery layer
  /// supplied one (Ollama's `<name>:<tag>`). Right-pane / info-pane
  /// callers fall back to `util::paths::model_display_name` when this
  /// returns `None` so non-Ollama rows keep their file_stem render.
  pub fn display_label_for(&self, path: &Path) -> Option<String> {
    self
      .models
      .iter()
      .find(|m| m.path == path)
      .and_then(|m| m.display_label.clone())
  }

  /// Friendly display name with fallback. Prefers the discovery
  /// label, falls back to the path's file_stem. Centralised so every
  /// surface (toast, confirm dialog, header, daemon panel) renders
  /// the same name for the same model.
  pub fn display_name_for(&self, path: &Path) -> String {
    self
      .display_label_for(path)
      .unwrap_or_else(|| crate::util::paths::model_display_name(path))
  }

  /// Display name of the focused model.
  pub fn focused_name(&self) -> Option<String> {
    let rows = self.rendered_rows();
    match rows.get(self.list_cursor) {
      Some(ListRow::Model { name, .. }) => Some(name.clone()),
      _ => None,
    }
  }

  pub fn focused_managed(&self) -> Option<&ManagedRow> {
    let rows = self.rendered_rows();
    let row = rows.get(self.list_cursor)?;
    match row {
      ListRow::Model {
        launch_id: Some(id),
        ..
      } => self.managed.iter().find(|m| &m.launch_id == id),
      ListRow::Model { path, .. } => self.managed.iter().find(|m| &m.path == path),
      _ => None,
    }
  }

  /// The ManagedRow the right pane should display — the model the
  /// cursor sits on, or `None` when the cursor row has no managed
  /// launch. The right pane follows the cursor with no sticky
  /// fallback: an unlaunched row shows just the Settings tab for
  /// the selected model.
  pub fn right_pane_focus(&self) -> Option<&ManagedRow> {
    self.focused_managed()
  }

  /// Build a [`LaunchPickerState`] seeded for the currently
  /// focused model — pure, no side effects. Returns `None` when
  /// the cursor sits on a header (no model focused). Both
  /// [`Self::open_launch_picker`] and the Settings tab's
  /// default-render path use this so the form contents (ctx,
  /// reasoning, preset_idx, prefer_port, active_instances) reflect
  /// persisted `last_params` even before the user has interacted
  /// with the picker.
  pub fn build_default_picker(&self) -> Option<LaunchPickerState> {
    let name = self.focused_name()?;
    let path = self.focused_path();
    let active_count = path
      .as_ref()
      .map(|p| self.managed.iter().filter(|m| &m.path == p).count())
      .unwrap_or(0);
    let mut state = LaunchPickerState::for_model(name);
    if let Some(p) = &path {
      if let Some(last) = self.last_params.get(p) {
        state.prefer_port = last.port;
        // R20: returning user inherits the typed-knob deltas they
        // last shipped. The daemon persists only user-supplied
        // deltas (not the fully resolved set) so seeding straight
        // into `user_knobs` keeps the picker's source labels
        // honest — every persisted knob shows `(user)`, the rest
        // re-resolve from yaml / built-in / model default.
        //
        // `ctx` and `reasoning` are now part of `TypedKnobs`, so
        // they ride along inside `last.knobs` without dedicated
        // seeding paths.
        state.user_knobs = last.knobs.clone();
      }
    }
    state.active_instances = active_count;
    Some(state)
  }

  /// Drill into the focused model row — the action `Enter` fires on
  /// the Models list. Two branches:
  ///
  /// - **Focused row is running** (`focused_managed().is_some()`):
  ///   focus the right pane in its read-only running view. *No*
  ///   picker is staged — pressing `Enter` alone shouldn't silently
  ///   spin up a duplicate launch before the user has any chance to
  ///   inspect or edit the params. The bottom-strip chip leads with
  ///   `e:edit for launch`; the user goes `e → edit fields → Enter`
  ///   to launch a new instance.
  /// - **Focused row is idle**: stage the launch picker with
  ///   last-used params so the next `Enter` (on the Settings tab)
  ///   dispatches the launch.
  ///
  /// Header rows / empty selections no-op. This is the "show or
  /// focus the right pane" gesture; the heavy-handed
  /// [`open_launch_picker`](Self::open_launch_picker) gate is
  /// reserved for the explicit `e:edit for launch` flow that
  /// dispatches from running view.
  pub fn drill_into_focused_model(&mut self) {
    if self.focused_name().is_none() {
      return;
    }
    if self.focused_managed().is_some() {
      self.right_tab = RightTab::Settings;
      self.focus = Focus::RightPane;
      self.running_view_scroll.set(0);
      return;
    }
    self.open_launch_picker();
  }

  /// Open the launch picker for the focused model. Seeds from
  /// persisted `last_params` (R20) when the daemon has reported any
  /// for the focused path, so a returning user lands on the params
  /// they last shipped. No-op when the cursor is on a header.
  ///
  /// Called by `e:edit for launch` (the explicit stage-over-running
  /// gate) and by `Enter`-on-list for idle rows via
  /// [`drill_into_focused_model`](Self::drill_into_focused_model).
  pub fn open_launch_picker(&mut self) {
    let mut picker = match self.build_default_picker() {
      Some(p) => p,
      None => return,
    };
    // Seed the extras buffer too when persisted extras exist for
    // the focused path. This side effect is owned by
    // `open_launch_picker` — `build_default_picker` stays pure so
    // render paths can call it freely.
    if let Some(path) = self.focused_path() {
      if let Some(last) = self.last_params.get(&path) {
        if !last.extras.is_empty() {
          picker.extras = last.extras.iter().map(std::ffi::OsString::from).collect();
        }
      }
    }
    self.launch_picker = Some(picker);
    self.running_view_scroll.set(0);
    self.right_tab = RightTab::Settings;
    self.focus = Focus::RightPane;
  }

  pub fn close_launch_picker(&mut self) {
    self.launch_picker = None;
    self.running_view_scroll.set(0);
    self.focus = Focus::List;
    self.right_tab = RightTab::Settings;
  }

  pub fn open_filter(&mut self) {
    self.focus = Focus::Filter;
    // Auto-enter edit so the user can type immediately. The Esc
    // walk-back (exit-edit → clear → close) handles teardown.
    self.filter_input.enter_edit();
  }

  /// Close the filter input entirely (matches the legacy
  /// `Esc clears + leaves filter mode` behaviour for callers that
  /// still want the one-shot reset). Distinct from
  /// [`crate::tui::input_field::InputField`]'s `Esc` walk-back, which only exits edit / clears
  /// the buffer; this resets both at once.
  pub fn clear_filter(&mut self) {
    self.filter_input.clear();
    self.filter_input.exit_edit();
    self.focus = Focus::List;
    self.clamp_cursor();
  }

  /// Apply a transient toast.
  pub fn show_toast(&mut self, msg: impl Into<String>) {
    self.toast = Some((msg.into(), Instant::now()));
  }

  /// Drop the toast if it's older than `TOAST_TTL`.
  pub fn expire_toast(&mut self) {
    if let Some((_, at)) = &self.toast {
      if at.elapsed() > TOAST_TTL {
        self.toast = None;
      }
    }
  }

  pub fn toast_message(&self) -> Option<&str> {
    self.toast.as_ref().map(|(s, _)| s.as_str())
  }

  /// Tabs the right pane should expose for the currently focused
  /// model. The rule is binary: a *running* selection (Launching /
  /// Loading / Ready) gets Logs + the mode-appropriate input tab +
  /// Settings; an unlaunched / stopped / errored / unfocused
  /// selection gets only Settings. There's no sticky fallback —
  /// what the user sees in the right pane is the model under the
  /// cursor, nothing else.
  pub fn available_right_tabs(&self) -> Vec<RightTab> {
    if let Some(cached) = self.right_tabs_cache.as_ref() {
      return cached.clone();
    }
    self.available_right_tabs_uncached()
  }

  /// The expensive compute path. Public only for the cache primer.
  fn available_right_tabs_uncached(&self) -> Vec<RightTab> {
    let managed = match self.focused_managed() {
      Some(m) => m,
      None => return vec![RightTab::Settings],
    };
    match managed.state {
      SurfaceState::Ready => {
        let mode = self
          .models
          .iter()
          .find(|m| m.path == managed.path)
          .and_then(|m| m.metadata.as_ref())
          .map(|md| md.mode_hint)
          .unwrap_or(crate::gguf::metadata::ModeHint::Chat);
        tabs_for_mode(mode)
      }
      // Process alive but not yet serving — Settings stays the
      // canonical first stop so the user can still tweak relaunch
      // params, Logs sits next so the startup pipeline is one
      // Tab away.
      SurfaceState::Launching | SurfaceState::Loading => {
        vec![RightTab::Settings, RightTab::Logs]
      }
      // Error: surface Logs alongside Settings so the user can
      // read the failure tail without re-launching. The daemon
      // keeps the per-launch log buffer around after the spawn
      // fails, and the poller still hits it because the entry
      // remains in `state.running` until the user clears it.
      SurfaceState::Error => vec![RightTab::Settings, RightTab::Logs],
      _ => vec![RightTab::Settings],
    }
  }

  /// Advance the right-pane tab. Skips tabs that aren't reachable
  /// for the current focus (e.g. Chat when the model isn't Ready).
  pub fn cycle_right_tab(&mut self) {
    let tabs = self.available_right_tabs();
    let Some(first) = tabs.first().copied() else {
      self.right_tab = RightTab::Settings;
      return;
    };
    let pos = tabs.iter().position(|t| *t == self.right_tab).unwrap_or(0);
    let next = (pos + 1) % tabs.len();
    self.right_tab = tabs.get(next).copied().unwrap_or(first);
  }

  /// Cycle to the previous right-pane tab — used by `Left` arrow
  /// alongside [`Self::cycle_right_tab`] (`Right` arrow / Tab).
  pub fn cycle_right_tab_prev(&mut self) {
    let tabs = self.available_right_tabs();
    let Some(first) = tabs.first().copied() else {
      self.right_tab = RightTab::Settings;
      return;
    };
    let pos = tabs.iter().position(|t| *t == self.right_tab).unwrap_or(0);
    let prev = (pos + tabs.len() - 1) % tabs.len();
    self.right_tab = tabs.get(prev).copied().unwrap_or(first);
  }

  /// Clamp `right_tab` back to a reachable choice if the focused
  /// model's available tabs shrink (e.g. the model dropped from
  /// Ready to Stopped, or the cursor moved to an unlaunched row).
  /// Snaps to the first reachable tab — Round-8 fixed a latent bug
  /// where the fallback was hardcoded to `Logs`, which isn't part
  /// of the reachable set for unlaunched models, leaving the right
  /// pane painting nothing for those rows. Called by the renderer
  /// before drawing.
  pub fn ensure_right_tab_reachable(&mut self) {
    let tabs = self.available_right_tabs();
    if !tabs.contains(&self.right_tab) {
      self.right_tab = tabs.first().copied().unwrap_or(RightTab::Settings);
    }
  }

  /// Cycle to the next theme. Used by the `t` hotkey.
  ///
  /// `Custom` is part of the cycle only when a user palette is loaded
  /// (`options.custom_palette.is_some()`). Otherwise it would render
  /// as the macchiato fallback and feel like a no-op tick. Built-ins
  /// always cycle; the custom slot slips in after `mono`.
  pub fn cycle_theme(&mut self) {
    use strum::IntoEnumIterator;
    let order: Vec<ThemeName> = ThemeName::iter()
      .filter(|t| *t != ThemeName::Custom || self.options.custom_palette.is_some())
      .collect();
    if order.is_empty() {
      return;
    }
    let pos = order
      .iter()
      .position(|t| *t == self.options.theme)
      .unwrap_or(order.len() - 1);
    let next = order[(pos + 1) % order.len()];
    self.options.theme = next;
  }

  /// Reverse of [`Self::cycle_theme`] — walks the same active set
  /// backwards. Bound to `Shift+T` so a user who overshoots the
  /// theme they wanted with `t` can step back instead of cycling
  /// through every theme to land on it again.
  pub fn cycle_theme_prev(&mut self) {
    use strum::IntoEnumIterator;
    let order: Vec<ThemeName> = ThemeName::iter()
      .filter(|t| *t != ThemeName::Custom || self.options.custom_palette.is_some())
      .collect();
    if order.is_empty() {
      return;
    }
    let pos = order
      .iter()
      .position(|t| *t == self.options.theme)
      .unwrap_or(0);
    let prev = order[(pos + order.len() - 1) % order.len()];
    self.options.theme = prev;
  }
}

fn apply_filter(rows: &[ListRow], query: &str) -> Vec<ListRow> {
  // Only model rows take part in the rank — headers regroup
  // around the surviving models.
  let model_idx: Vec<usize> = rows
    .iter()
    .enumerate()
    .filter_map(|(i, r)| match r {
      ListRow::Model { .. } => Some(i),
      _ => None,
    })
    .collect();
  let names: Vec<String> = model_idx
    .iter()
    .filter_map(|i| match &rows[*i] {
      ListRow::Model {
        name, arch, quant, ..
      } => Some(format!("{name} {arch} {quant}")),
      _ => None,
    })
    .collect();
  let ranked = rank(query, &names);
  let kept: std::collections::BTreeSet<usize> = ranked.into_iter().map(|i| model_idx[i]).collect();
  // Reproduce the same section ordering, dropping headers whose
  // groups have no surviving model rows.
  let mut out: Vec<ListRow> = Vec::with_capacity(kept.len() + 4);
  let mut i = 0;
  while i < rows.len() {
    match &rows[i] {
      // The column-label row is always the first row in the
      // unfiltered list; preserve it at the top of the filtered
      // view so the columns still have labels.
      ListRow::TableHeader => {
        out.push(ListRow::TableHeader);
        i += 1;
      }
      ListRow::Header { .. } => {
        let header = rows[i].clone();
        let mut j = i + 1;
        let mut group: Vec<ListRow> = Vec::new();
        while j < rows.len() {
          if matches!(rows[j], ListRow::Header { .. } | ListRow::TableHeader) {
            break;
          }
          if kept.contains(&j) {
            group.push(rows[j].clone());
          }
          j += 1;
        }
        if !group.is_empty() {
          out.push(header);
          out.extend(group);
        }
        i = j;
      }
      ListRow::Model { .. } => {
        if kept.contains(&i) {
          out.push(rows[i].clone());
        }
        i += 1;
      }
      ListRow::Divider => {
        // Dividers are purely structural — they separate Favorites
        // from the folder groups in the unfiltered view. The
        // filtered view re-derives sections from kept rows, so the
        // divider has nothing to separate and just drops out.
        i += 1;
      }
    }
  }
  out
}

fn parse_list_models_row(row: &Value) -> Option<DiscoveredModel> {
  use crate::discovery::ModelSource;
  use crate::gguf::metadata::{ModelMetadata, Quant};

  let path = PathBuf::from(row.get("path")?.as_str()?);
  let parent = PathBuf::from(row.get("parent")?.as_str()?);
  let source = match row.get("source").and_then(Value::as_str)? {
    "user" => ModelSource::UserPath,
    "huggingface" => ModelSource::HuggingFace,
    "ollama" => ModelSource::Ollama,
    "lm-studio" => ModelSource::LmStudio,
    _ => ModelSource::UserPath,
  };
  let metadata = row.get("metadata").and_then(|md| {
    if md.is_null() {
      None
    } else {
      Some(ModelMetadata {
        arch: md.get("arch").and_then(Value::as_str).map(String::from),
        total_parameters: md.get("total_parameters").and_then(Value::as_u64),
        parameter_label: md
          .get("parameter_label")
          .and_then(Value::as_str)
          .map(String::from),
        quant: md
          .get("quant")
          .and_then(Value::as_str)
          .map(parse_quant)
          .unwrap_or_else(|| Quant::Unknown(0)),
        native_ctx: md.get("native_ctx").and_then(Value::as_u64),
        chat_template: None,
        tokenizer_kind: md
          .get("tokenizer_kind")
          .and_then(Value::as_str)
          .map(String::from),
        reasoning_hint: false,
        mode_hint: parse_mode_hint(md.get("mode_hint").and_then(Value::as_str)),
        weights_bytes: md.get("weights_bytes").and_then(Value::as_u64),
      })
    }
  });
  Some(DiscoveredModel {
    path,
    parent,
    source,
    metadata,
    parse_error: row
      .get("parse_error")
      .and_then(Value::as_str)
      .map(String::from),
    split_siblings: row
      .get("split_siblings")
      .and_then(Value::as_array)
      .map(|arr| {
        arr
          .iter()
          .filter_map(|v| v.as_str().map(PathBuf::from))
          .collect()
      })
      .unwrap_or_default(),
    display_label: row
      .get("display_label")
      .and_then(Value::as_str)
      .map(String::from),
  })
}

fn parse_mode_hint(label: Option<&str>) -> crate::gguf::metadata::ModeHint {
  use crate::gguf::metadata::ModeHint;
  match label {
    Some("chat") => ModeHint::Chat,
    Some("embedding") => ModeHint::Embedding,
    Some("rerank") => ModeHint::Rerank,
    _ => ModeHint::Unknown,
  }
}

fn parse_quant(label: &str) -> crate::gguf::metadata::Quant {
  // Route through the canonical `Quant::from_label` so the table
  // stays single-sourced; missing labels fall through to the
  // `Unknown(0)` sentinel without crashing the TUI on a future
  // quant tag the daemon learns about first. The `0` payload is
  // just "unknown ggml type" — not surfaced back to the user.
  crate::gguf::metadata::Quant::from_label(label)
    .unwrap_or(crate::gguf::metadata::Quant::Unknown(0))
}

fn parse_external_row(row: &Value) -> Option<ManagedRow> {
  let pid = row.get("pid").and_then(Value::as_u64)? as u32;
  let path = row
    .get("model_path")
    .and_then(Value::as_str)
    .map(PathBuf::from)
    .unwrap_or_default();
  Some(ManagedRow {
    launch_id: format!("ext-{pid}"),
    path,
    // External processes don't have an observable port from
    // sysinfo cmdline alone — surface 0 and let the right pane
    // know to hide the endpoint slot for these rows.
    port: 0,
    state: SurfaceState::External,
    rss_bytes: None,
    cpu_pct: None,
  })
}

/// Parse the daemon's `status.proxy` block into the TUI-side
/// [`ProxyInfo`] struct. Returns `None` for non-object / missing
/// shapes so an older daemon (pre-Unit-5) leaves the row off
/// instead of rendering a confusing "?" placeholder.
fn parse_proxy_info(v: &Value) -> Option<ProxyInfo> {
  let obj = v.as_object()?;
  let status = obj.get("status").and_then(Value::as_str)?.to_string();
  let enabled = obj
    .get("enabled")
    .and_then(Value::as_bool)
    .unwrap_or(status != "disabled");
  let listen = obj.get("listen").and_then(Value::as_str).map(String::from);
  let bind_error = obj
    .get("bind_error")
    .and_then(Value::as_str)
    .map(String::from);
  Some(ProxyInfo {
    enabled,
    listen,
    status,
    bind_error,
  })
}

fn parse_status_row(row: &Value) -> Option<ManagedRow> {
  let launch_id = row.get("launch_id")?.as_str()?.to_string();
  let port = row.get("port")?.as_u64()? as u16;
  let path = row
    .get("id")
    .and_then(|id| id.get("path"))
    .and_then(Value::as_str)
    .map(PathBuf::from)?;
  let state_label = row
    .get("state")
    .and_then(|s| s.get("state"))
    .and_then(Value::as_str)
    .unwrap_or("");
  let state = SurfaceState::from_wire_label(state_label);
  let rss_bytes = row.get("latest_rss_bytes").and_then(Value::as_u64);
  let cpu_pct = row
    .get("latest_cpu_pct")
    .and_then(Value::as_f64)
    .map(|n| n as f32);
  Some(ManagedRow {
    launch_id,
    path,
    port,
    state,
    rss_bytes,
    cpu_pct,
  })
}

#[cfg(test)]
mod tests {
  use super::*;
  use crate::discovery::ModelSource;
  use crate::gguf::metadata::{ModeHint, ModelMetadata, Quant};
  use serde_json::json;

  fn fake(path: &str, parent: &str) -> DiscoveredModel {
    DiscoveredModel {
      path: PathBuf::from(path),
      parent: PathBuf::from(parent),
      source: ModelSource::UserPath,
      metadata: Some(ModelMetadata {
        arch: Some("llama".into()),
        total_parameters: Some(7_000_000_000),
        parameter_label: Some("7B".into()),
        quant: Quant::Q4_K,
        native_ctx: Some(8192),
        chat_template: None,
        tokenizer_kind: None,
        reasoning_hint: false,
        mode_hint: ModeHint::Chat,
        weights_bytes: Some(4_200_000_000),
      }),
      parse_error: None,
      split_siblings: Vec::new(),
      display_label: None,
    }
  }

  #[test]
  fn parse_list_models_row_preserves_split_siblings() {
    // Regression: the parser used to throw away `split_siblings`,
    // which meant the TUI's shard-aware SIZE computation only ever
    // stat'd shard 1 and reported ~half the real size for split
    // GGUFs. The wire shape carries the siblings; the TUI needs
    // them to call `shard_sizes::on_disk_total` correctly.
    let row = json!({
      "path": "/m/m-00001-of-00002.gguf",
      "parent": "/m",
      "source": "huggingface",
      "split_siblings": [
        "/m/m-00002-of-00002.gguf",
      ],
      "metadata": {
        "arch": "qwen3next",
        "quant": "Q5_K",
        "weights_bytes": 1_000_000_000_u64,
      },
    });
    let parsed = parse_list_models_row(&row).expect("row parses");
    assert_eq!(parsed.split_siblings.len(), 1);
    assert_eq!(
      parsed.split_siblings[0],
      PathBuf::from("/m/m-00002-of-00002.gguf")
    );
  }

  #[test]
  fn move_up_and_down_skip_section_headers() {
    let mut app = App::new(AppOptions::default());
    app.models = vec![fake("/m/x/a.gguf", "/m/x"), fake("/m/y/b.gguf", "/m/y")];
    let rows = app.rendered_rows();
    // Layout: TableHeader, header(/m/x), model a, header(/m/y), model b
    assert_eq!(rows.len(), 5);
    app.list_cursor = 2; // model a
    app.move_down();
    assert_eq!(app.list_cursor, 4, "move_down skipped header to next model");
    app.move_up();
    assert_eq!(
      app.list_cursor, 2,
      "move_up went back past the group header"
    );
  }

  #[test]
  fn cycle_theme_walks_round_robin() {
    use strum::IntoEnumIterator;
    let mut app = App::new(AppOptions::default());
    let original = app.options.theme;
    // `Custom` is skipped when no custom palette is loaded, so a full
    // lap is the count of built-in (non-Custom) themes.
    let total = ThemeName::iter()
      .filter(|t| *t != ThemeName::Custom)
      .count();
    for _ in 0..total {
      app.cycle_theme();
    }
    assert_eq!(app.options.theme, original, "wraps after one full lap");
  }

  #[test]
  fn cycle_theme_includes_custom_when_palette_loaded() {
    use strum::IntoEnumIterator;
    let custom = crate::theme::CustomThemeConfig::default().resolve().0;
    let mut app = App::new(AppOptions {
      theme: ThemeName::Macchiato,
      custom_palette: Some(custom),
      keymap: KeyMap::default(),
      ..Default::default()
    });
    let total = ThemeName::iter().count();
    let mut saw_custom = false;
    for _ in 0..total {
      app.cycle_theme();
      if app.options.theme == ThemeName::Custom {
        saw_custom = true;
      }
    }
    assert!(
      saw_custom,
      "cycle should hit Custom when a palette is loaded"
    );
  }

  #[test]
  fn cycle_theme_skips_custom_when_no_palette_loaded() {
    use strum::IntoEnumIterator;
    let mut app = App::new(AppOptions::default());
    let total = ThemeName::iter().count();
    for _ in 0..total {
      app.cycle_theme();
      assert_ne!(
        app.options.theme,
        ThemeName::Custom,
        "Custom should never appear in the cycle without a loaded palette"
      );
    }
  }

  #[test]
  fn ingest_list_models_round_trips_through_ipc_shape() {
    let mut app = App::new(AppOptions::default());
    let body = json!({
      "models": [
        {
          "path": "/m/a.gguf",
          "parent": "/m",
          "source": "huggingface",
          "metadata": {
            "arch": "llama",
            "quant": "Q4_K",
            "native_ctx": 8192,
            "mode_hint": "chat",
            "parameter_label": "7B",
          },
          "parse_error": null,
          "split_siblings": []
        }
      ]
    });
    app.ingest_list_models(&body);
    assert_eq!(app.models.len(), 1);
    assert_eq!(app.models[0].path, PathBuf::from("/m/a.gguf"));
  }

  #[test]
  fn ingest_status_populates_managed_rows() {
    let mut app = App::new(AppOptions::default());
    let body = json!({
      "models": [{
        "launch_id": "L1",
        "id": {"path": "/m/a.gguf", "header_blake3": "00".repeat(32)},
        "port": 41100,
        "mode": "chat",
        "pid": 1234,
        "ready_at": null,
        "state": {"state": "ready"},
        "latest_rss_bytes": 4_500_000_000_u64,
        "latest_cpu_pct": 312.0,
      }],
      "gpu": {"backend": "cpu_only"}
    });
    app.ingest_status(&body);
    assert_eq!(app.managed.len(), 1);
    assert_eq!(app.managed[0].launch_id, "L1");
    assert_eq!(app.managed[0].state, SurfaceState::Ready);
    assert_eq!(app.managed[0].rss_bytes, Some(4_500_000_000));
    assert_eq!(app.managed[0].cpu_pct, Some(312.0));
  }

  #[test]
  fn ingest_status_populates_daemon_info_from_daemon_block() {
    let mut app = App::new(AppOptions::default());
    let body = json!({
      "daemon": {
        "pid": 2222,
        "uptime_seconds": 9_000,
        "build": "0.1.0",
        "server_path": "/usr/bin/llama-server"
      }
    });
    app.ingest_status(&body);
    assert_eq!(app.daemon_info.pid, Some(2222));
    assert_eq!(app.daemon_info.uptime_seconds, Some(9_000));
    assert_eq!(app.daemon_info.build.as_deref(), Some("0.1.0"));
    assert_eq!(
      app.daemon_info.server_path.as_deref(),
      Some("/usr/bin/llama-server")
    );
  }

  #[test]
  fn ingest_status_populates_proxy_info_from_proxy_block() {
    let mut app = App::new(AppOptions::default());
    let body = json!({
      "daemon": {"pid": 1, "uptime_seconds": 0, "active_connections": 0},
      "proxy": {
        "enabled": true,
        "listen": "127.0.0.1:11434",
        "status": "listening",
        "bind_error": null,
      }
    });
    app.ingest_status(&body);
    let proxy = app.daemon_info.proxy.as_ref().expect("proxy info parsed");
    assert!(proxy.enabled);
    assert_eq!(proxy.listen.as_deref(), Some("127.0.0.1:11434"));
    assert_eq!(proxy.status, "listening");
    assert!(proxy.bind_error.is_none());
  }

  #[test]
  fn ingest_status_toasts_once_on_port_in_use_transition() {
    // Plan §Approach: toast fires on the *transition* into
    // port_in_use, not on every poll. A second consecutive
    // port_in_use observation must not re-fire the toast.
    let mut app = App::new(AppOptions::default());
    let listening = json!({
      "daemon": {"pid": 1, "uptime_seconds": 0, "active_connections": 0},
      "proxy": {
        "enabled": true,
        "listen": "127.0.0.1:11434",
        "status": "listening",
        "bind_error": null,
      }
    });
    app.ingest_status(&listening);
    assert!(app.toast.is_none(), "listening must not toast");

    let collided = json!({
      "daemon": {"pid": 1, "uptime_seconds": 0, "active_connections": 0},
      "proxy": {
        "enabled": true,
        "listen": "127.0.0.1:11434",
        "status": "port_in_use",
        "bind_error": null,
      }
    });
    app.ingest_status(&collided);
    let first = app
      .toast
      .clone()
      .expect("transition into port_in_use must toast");
    assert!(
      first.0.contains("port") && first.0.contains("11434"),
      "toast must name the listen address: {first:?}"
    );

    // Clear the toast (simulate it expiring) and re-ingest the same
    // port_in_use status. No new toast should fire.
    app.toast = None;
    app.ingest_status(&collided);
    assert!(
      app.toast.is_none(),
      "subsequent identical port_in_use ticks must NOT re-toast"
    );
  }

  #[test]
  fn ingest_status_populates_host_metrics_from_host_block() {
    let mut app = App::new(AppOptions::default());
    let body = json!({
      "host": {
        "cpu_pct": 47.5,
        "ram_used_bytes": 1_073_741_824_u64,
        "ram_total_bytes": 17_179_869_184_u64,
        "gpu_util_pct": 84.0,
        "gpu_mem_used_bytes": 14_000_000_000_u64,
        "gpu_mem_total_bytes": 24_000_000_000_u64,
        "gpu_temp_c": 68.0,
        "gpu_backend": "nvidia",
        "gpu_device_count": 1
      }
    });
    app.ingest_status(&body);
    assert_eq!(app.host_metrics.gpu_backend, "nvidia");
    assert_eq!(app.host_metrics.ram_total_bytes, 17_179_869_184);
    assert_eq!(app.host_metrics.gpu_util_pct, Some(84.0));
  }

  #[test]
  fn ingest_status_clears_managed_when_models_field_absent() {
    // Schema-evolution or framing error must not leave a ghost
    // ManagedRow visible — symmetric with the `external` clear path.
    let mut app = App::new(AppOptions::default());
    let with_models = json!({
      "models": [{
        "launch_id": "L1",
        "id": {"path": "/m/a.gguf", "header_blake3": "00".repeat(32)},
        "port": 41100,
        "mode": "chat",
        "pid": 1,
        "ready_at": null,
        "state": {"state": "ready"}
      }]
    });
    app.ingest_status(&with_models);
    assert_eq!(app.managed.len(), 1);
    // A subsequent status with no `models` field clears managed.
    let without_models = json!({});
    app.ingest_status(&without_models);
    assert!(
      app.managed.is_empty(),
      "managed must clear when field absent"
    );
  }

  #[test]
  fn open_launch_picker_no_op_on_header_focus() {
    let mut app = App::new(AppOptions::default());
    app.models = vec![fake("/m/a.gguf", "/m")];
    app.list_cursor = 0; // header row
    app.open_launch_picker();
    assert!(
      app.launch_picker.is_none(),
      "header focus must not open a picker"
    );
  }

  #[test]
  fn drill_into_focused_model_stages_picker_when_idle() {
    // Idle row: Enter-on-list → drill stages the picker so the
    // *next* Enter dispatches a launch.
    let mut app = App::new(AppOptions::default());
    app.models = vec![fake("/m/a.gguf", "/m")];
    app.list_cursor = 2;
    assert!(app.focused_managed().is_none());
    app.drill_into_focused_model();
    assert!(
      app.launch_picker.is_some(),
      "idle drill must stage the picker so Enter → launch reads"
    );
    assert_eq!(app.focus, Focus::RightPane);
    assert_eq!(app.right_tab, RightTab::Settings);
  }

  #[test]
  fn drill_into_focused_model_skips_picker_when_running() {
    // Running row: Enter-on-list should *not* stage a launch
    // picker — it just shows the read-only running view. The
    // user explicitly stages via `e:edit for launch`, then Enter
    // launches. Avoids accidentally dispatching a duplicate
    // instance from a stray Enter.
    let m = fake("/m/a.gguf", "/m");
    let mut app = App::new(AppOptions::default());
    app.models = vec![m.clone()];
    app.managed = vec![ManagedRow {
      launch_id: "L1".into(),
      path: m.path.clone(),
      port: 41100,
      state: SurfaceState::Ready,
      rss_bytes: None,
      cpu_pct: None,
    }];
    app.list_cursor = 2;
    assert!(app.focused_managed().is_some());
    app.drill_into_focused_model();
    assert!(
      app.launch_picker.is_none(),
      "running drill must NOT stage a picker — only `e` does that"
    );
    assert_eq!(
      app.focus,
      Focus::RightPane,
      "drill still focuses the right pane (read-only running view)"
    );
    assert_eq!(app.right_tab, RightTab::Settings);
  }

  #[test]
  fn drill_into_focused_model_noop_on_header_focus() {
    let mut app = App::new(AppOptions::default());
    app.models = vec![fake("/m/a.gguf", "/m")];
    app.list_cursor = 0;
    app.drill_into_focused_model();
    assert!(app.launch_picker.is_none());
    assert_ne!(
      app.focus,
      Focus::RightPane,
      "header drill must not move focus"
    );
  }

  #[test]
  fn open_launch_picker_carries_model_name_and_routes_to_settings_tab() {
    let mut app = App::new(AppOptions::default());
    app.models = vec![fake("/m/a.gguf", "/m")];
    // Rows: [TableHeader, Header(/m), Model] — model is at index 2.
    app.list_cursor = 2;
    app.open_launch_picker();
    let picker = app.launch_picker.as_ref().expect("picker state");
    assert_eq!(picker.model_name, "a");
    // New behaviour: launch picker no longer pops a centred modal;
    // it parks focus on the right pane's Settings tab and forces
    // the pane open if no model is currently running.
    assert_eq!(app.focus, Focus::RightPane);
    assert_eq!(app.right_tab, RightTab::Settings);
    // Wide width: pane shows regardless of focus (models non-empty).
    assert!(
      app.right_pane_visible_at(120),
      "right pane must be visible after open_launch_picker (wide)"
    );
    // Compact width: pane shows because focus drilled into RightPane.
    assert!(
      app.right_pane_visible_at(60),
      "right pane must be visible after open_launch_picker (compact)"
    );
  }

  #[test]
  fn open_launch_picker_prefills_from_persisted_last_params() {
    // Item 6: a returning user lands on the same ctx / reasoning /
    // advanced argv they shipped on the previous launch. The daemon
    // exposes the snapshot via `last_params_list`; the App ingests it
    // into `self.last_params`; the picker seeds from it.
    let mut app = App::new(AppOptions::default());
    let path = PathBuf::from("/m/a.gguf");
    app.models = vec![fake("/m/a.gguf", "/m")];
    app.list_cursor = 2;
    app.last_params.insert(
      path.clone(),
      LastParamsRow {
        ctx: Some(16384),
        reasoning: true,
        knobs: crate::config::TypedKnobs {
          ctx: Some(16384),
          reasoning: Some(true),
          ..Default::default()
        },
        extras: vec!["--rope-freq-base".into(), "10000".into()],
        port: Some(41105),
      },
    );
    app.open_launch_picker();
    let picker = app.launch_picker.as_ref().expect("picker state");
    assert_eq!(
      picker.user_knobs.ctx,
      Some(16384),
      "ctx must seed from last_params via user_knobs"
    );
    assert_eq!(
      picker.user_knobs.reasoning,
      Some(true),
      "reasoning must seed from last_params via user_knobs"
    );
    assert_eq!(picker.prefer_port, Some(41105), "port must seed too");
    let extras: Vec<String> = picker
      .extras
      .iter()
      .map(|s| s.to_string_lossy().into_owned())
      .collect();
    assert_eq!(extras, vec!["--rope-freq-base", "10000"]);
  }

  fn ready_managed(path: &str, port: u16, state: SurfaceState) -> ManagedRow {
    ManagedRow {
      launch_id: format!("L-{port}"),
      path: PathBuf::from(path),
      port,
      state,
      rss_bytes: None,
      cpu_pct: None,
    }
  }

  #[test]
  fn ingest_status_snaps_right_tab_to_logs_on_error_transition() {
    // When the focused launch transitions from Loading → Error, the
    // right pane should auto-switch to Logs so the failure tail is
    // visible without an extra keystroke. Settings tab is too far
    // from the cause of failure for a user who just saw the launch
    // fail.
    let mut app = App::new(AppOptions::default());
    app.models = vec![fake("/m/qwen.gguf", "/m")];
    let loading = serde_json::json!({
      "models": [{
        "launch_id": "L1",
        "id": { "path": "/m/qwen.gguf", "header_hash": "h" },
        "port": 41100,
        "state": { "state": "loading" },
      }]
    });
    app.ingest_status(&loading);
    app.right_tab = RightTab::Settings;
    let errored = serde_json::json!({
      "models": [{
        "launch_id": "L1",
        "id": { "path": "/m/qwen.gguf", "header_hash": "h" },
        "port": 41100,
        "state": { "state": "error", "cause": "probe timeout" },
      }]
    });
    app.ingest_status(&errored);
    assert_eq!(
      app.right_tab,
      RightTab::Logs,
      "Error transition should snap the right pane to Logs"
    );
    // Logs is also reachable for an Error row going forward (so a
    // user who arrives at the row later still sees the tab).
    assert!(
      app.available_right_tabs().contains(&RightTab::Logs),
      "Error rows must expose the Logs tab"
    );
  }

  #[test]
  fn ingest_status_snaps_cursor_to_newly_appeared_launch() {
    // A new launch_id arriving in a status tick should pull the
    // cursor onto its Running row so the user sees the model they
    // just launched selected — matches the kdash-style "latest
    // run goes to top + becomes selection" behaviour the user
    // confirmed during planning.
    let mut app = App::new(AppOptions::default());
    app.models = vec![fake("/m/qwen.gguf", "/m")];
    let body = serde_json::json!({
      "models": [{
        "launch_id": "L1",
        "id": { "path": "/m/qwen.gguf", "header_hash": "h" },
        "port": 41100,
        "state": { "state": "ready" },
      }]
    });
    app.ingest_status(&body);
    // Row 2 should be the Running qwen row.
    let rows = app.rendered_rows();
    let cursor_row = rows.get(app.list_cursor).expect("cursor lands in bounds");
    match cursor_row {
      ListRow::Model {
        launch_id: Some(id),
        ..
      } => assert_eq!(id, "L1", "cursor must land on the new launch"),
      other => panic!("cursor must land on a launch row, got {other:?}"),
    }
  }

  #[test]
  fn focused_managed_uses_launch_id_when_present_for_duplicate_launches() {
    // When two launches share a path, the Running rows must
    // disambiguate by launch_id — picking the wrong one would
    // route Logs/Chat/Settings to the other instance.
    let mut app = App::new(AppOptions::default());
    app.models = vec![fake("/m/qwen.gguf", "/m")];
    app.managed = vec![
      ready_managed("/m/qwen.gguf", 41100, SurfaceState::Ready),
      ready_managed("/m/qwen.gguf", 41101, SurfaceState::Ready),
    ];
    // Row layout: [TableHeader, Header(▶ Running), Model(L-41100), Model(L-41101), Header(/m), Model(qwen catalog)]
    // The merge order in `ingest_status` is "new launches first"
    // but here we set `app.managed` directly, so the rows reflect
    // Vec order. The first managed row (L-41100) appears first.
    app.list_cursor = 2;
    let first = app.focused_managed().expect("focused managed at row 2");
    assert_eq!(first.launch_id, "L-41100");
    app.list_cursor = 3;
    let second = app.focused_managed().expect("focused managed at row 3");
    assert_eq!(second.launch_id, "L-41101");
  }

  #[test]
  fn right_pane_follows_cursor_no_sticky_fallback() {
    // Two models — one running (qwen), one not (phi). The list now
    // pins a `▶ Running` section at the top with a per-launch row,
    // and the running path drops out of its catalog group so it
    // never shows twice. Row layout:
    //   0: TableHeader
    //   1: Header(▶ Running)
    //   2: Model(qwen, launch_id) — Ready
    //   3: Header(/m)
    //   4: Model(phi) — NotLaunched
    let mut app = App::new(AppOptions::default());
    app.models = vec![fake("/m/qwen.gguf", "/m"), fake("/m/phi.gguf", "/m")];
    app.managed = vec![ready_managed("/m/qwen.gguf", 41100, SurfaceState::Ready)];
    app.list_cursor = 2;
    assert_eq!(
      app.available_right_tabs(),
      vec![RightTab::Settings, RightTab::Logs, RightTab::Chat],
      "Running-row selection exposes mode-appropriate tabs (Settings first)"
    );
    assert!(app.right_pane_focus().is_some());

    // phi has no managed launch → Settings-only.
    app.list_cursor = 4;
    assert_eq!(
      app.available_right_tabs(),
      vec![RightTab::Settings],
      "unlaunched selection collapses to Settings only"
    );
    assert!(
      app.right_pane_focus().is_none(),
      "no sticky fallback — right pane has no managed handle to draw"
    );

    // Running paths must not duplicate into Favorites / folder
    // groups — only the Running-section row should carry qwen.
    let rows = app.rendered_rows();
    let qwen_rows = rows
      .iter()
      .filter(|r| match r {
        ListRow::Model { path, .. } => path.ends_with("qwen.gguf"),
        _ => false,
      })
      .count();
    assert_eq!(
      qwen_rows, 1,
      "running qwen must appear only in the Running group, got {qwen_rows} rows"
    );
  }

  #[test]
  fn loading_selection_shows_logs_and_settings_but_not_chat() {
    // A model mid-startup (Launching/Loading) keeps Logs visible so
    // the user can watch the pipeline; Chat would mislead — the
    // server isn't accepting requests yet.
    let mut app = App::new(AppOptions::default());
    app.models = vec![fake("/m/qwen.gguf", "/m")];
    app.managed = vec![ready_managed("/m/qwen.gguf", 41100, SurfaceState::Loading)];
    app.list_cursor = 2;
    assert_eq!(
      app.available_right_tabs(),
      vec![RightTab::Settings, RightTab::Logs]
    );
  }

  #[test]
  fn filter_keeps_matching_models_and_drops_empty_groups() {
    let mut app = App::new(AppOptions::default());
    app.models = vec![
      fake("/m/x/qwen.gguf", "/m/x"),
      fake("/m/y/phi.gguf", "/m/y"),
    ];
    app.filter_input.set_text("qwen");
    let rows = app.rendered_rows();
    let names: Vec<String> = rows
      .iter()
      .filter_map(|r| match r {
        ListRow::Model { name, .. } => Some(name.clone()),
        _ => None,
      })
      .collect();
    assert_eq!(names, vec!["qwen".to_string()]);
    let headers: Vec<String> = rows
      .iter()
      .filter_map(|r| match r {
        ListRow::Header { label } => Some(label.clone()),
        _ => None,
      })
      .collect();
    assert_eq!(
      headers,
      vec!["m/x".to_string()],
      "empty groups must be dropped"
    );
  }

  #[test]
  fn cursor_move_clears_stale_launch_picker_when_path_changes() {
    // Round-8: the right pane is tied to the focused row. A
    // picker staged for model A must not survive a scroll to
    // model B — otherwise the Settings tab paints A's name + ctx
    // form while the user is looking at B.
    let mut app = App::new(AppOptions::default());
    app.models = vec![fake("/m/x/a.gguf", "/m/x"), fake("/m/y/b.gguf", "/m/y")];
    app.list_cursor = 2; // model a
    app.open_launch_picker();
    assert_eq!(app.launch_picker.as_ref().unwrap().model_name, "a");
    app.move_down();
    assert!(
      app.launch_picker.is_none(),
      "scrolling to a different model must clear the stale picker"
    );
  }

  #[test]
  fn ensure_right_tab_reachable_snaps_to_settings_for_unlaunched_focus() {
    // Round-8 fix: the fallback used to be hardcoded to Logs even
    // for unlaunched models — leaving `right_tab = Logs` while
    // `available_right_tabs()` returns `[Settings]`. Now the
    // fallback walks the reachable list and picks the first entry.
    let mut app = App::new(AppOptions::default());
    app.models = vec![fake("/m/a.gguf", "/m")];
    app.list_cursor = 2;
    app.right_tab = RightTab::Chat;
    app.ensure_right_tab_reachable();
    assert_eq!(app.right_tab, RightTab::Settings);
  }

  #[test]
  fn rendered_rows_cache_returns_memoized_value_inside_frame() {
    // Tier-C hoist: `rendered_rows()` is memoized via `rows_cache`
    // for the duration of a single render frame. The primer must
    // populate the cache, and subsequent calls must return the
    // memoized rows even if a state mutation lands between the
    // primer and the consumer (which is exactly what happens
    // during a frame — the cache holds the rows the *frame*
    // committed to, not a stale half-build).
    let mut app = App::new(AppOptions::default());
    app.models = vec![fake("/m/x/a.gguf", "/m/x")];
    let baseline = app.rendered_rows();
    app.prime_rows_cache();
    // Mutate the underlying model set; cache MUST hide the change
    // until cleared.
    app.models.push(fake("/m/y/b.gguf", "/m/y"));
    let cached = app.rendered_rows();
    assert_eq!(
      cached.len(),
      baseline.len(),
      "cache must hide mid-frame mutations"
    );
    app.clear_rows_cache();
    let fresh = app.rendered_rows();
    assert!(
      fresh.len() > baseline.len(),
      "post-clear render reflects the mutation"
    );
  }

  #[test]
  fn cycle_right_tab_falls_back_to_settings_when_unreachable() {
    // F1 #2: the cycle helpers used to hardcode `Logs` as the
    // empty-set fallback, contradicting `ensure_right_tab_reachable`
    // which lands on Settings. Now both helpers walk the reachable
    // list and snap to its first entry (Settings is universal).
    let mut app = App::new(AppOptions::default());
    // No models loaded → focused_managed() is None → tabs = [Settings]
    app.right_tab = RightTab::Logs;
    app.cycle_right_tab();
    assert_eq!(
      app.right_tab,
      RightTab::Settings,
      "cycle_right_tab must land in the reachable set"
    );
    app.right_tab = RightTab::Chat;
    app.cycle_right_tab_prev();
    assert_eq!(
      app.right_tab,
      RightTab::Settings,
      "cycle_right_tab_prev must land in the reachable set"
    );
  }

  #[test]
  fn snap_cursor_to_launch_clears_stale_launch_picker() {
    // F1 #3: `snap_cursor_to_launch` is the only `list_cursor`
    // writer that used to skip `sync_picker_to_focus`. When a
    // status snapshot lands during launch and snaps to a different
    // path than the picker was staged for, Settings would render
    // ports/name for the *previous* path. Snap must now clear the
    // stale picker just like `move_up` / `move_down` do.
    //
    // With managed = [b], the row layout is:
    //   0: TableHeader
    //   1: Header(▶ Running)
    //   2: Model b (launch L-42100)
    //   3: Header(/m/x)
    //   4: Model a (catalog)
    // (empty /m/y group is pruned by `build_rows`)
    let mut app = App::new(AppOptions::default());
    app.models = vec![fake("/m/x/a.gguf", "/m/x"), fake("/m/y/b.gguf", "/m/y")];
    app.managed = vec![ready_managed("/m/y/b.gguf", 42100, SurfaceState::Launching)];
    app.list_cursor = 4; // model a (catalog row)
    app.open_launch_picker();
    assert_eq!(app.launch_picker.as_ref().unwrap().model_name, "a");
    app.snap_cursor_to_launch("L-42100");
    assert_eq!(app.list_cursor, 2, "snap must move cursor to launch row");
    assert!(
      app.launch_picker.is_none(),
      "snap_cursor_to_launch must clear a picker staged for the prior path"
    );
  }

  #[test]
  fn toast_expires_after_ttl() {
    let mut app = App::new(AppOptions::default());
    app.show_toast("yanked");
    assert!(app.toast_message().is_some());
    // Backdate the toast to force expiry.
    if let Some((_, ref mut at)) = app.toast {
      *at = Instant::now() - Duration::from_secs(10);
    }
    app.expire_toast();
    assert!(app.toast_message().is_none());
  }
}
