use std::io;
use std::path::Path;
use std::time::Duration;

use anyhow::Result;
use crossterm::event::{
    self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent,
    KeyEventKind, KeyModifiers, KeyboardEnhancementFlags, MouseButton,
    MouseEvent, MouseEventKind, PopKeyboardEnhancementFlags,
    PushKeyboardEnhancementFlags,
};
use crossterm::execute;
use crossterm::terminal::{
    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders};
use tui_textarea::{CursorMove, TextArea};
use uuid::Uuid;

use crate::ai::AiClient;
use crate::ai::prompts::PromptLibrary;
use crate::ai::stream::{ChatTurn, StreamMsg, spawn_chat_stream};
use crate::config::Config;
use crate::error::{Error, Result as InkResult};
use crate::manuscript::is_scene_break;
use crate::project::ProjectLayout;
use crate::store::Store;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::{Node, NodeKind};
use crate::store::InsertPosition;

use super::file_picker::{FilePicker, PickerContext};
use super::focus::Focus;
use super::quickref;
use super::search_replace::SearchState;
use super::session::{
    ParagraphCursor, SessionState, TimelineViewSnapshot,
};
use super::highlight::{
    BlockSelection, TypstHighlighter,
};
use super::backup_ui::maybe_auto_backup;
use super::credits::embedded_logo_image;
use super::input::TextInput;
use super::lexicon_build::{LexiconKind, build_lexicon};
use super::modal::{
    EventPickerEntry, ImagePickerEntry, Modal, PagesToSave, PromptBody,
    PromptSource, RenderedPageProto, ScriptPickerEntry, ScriptPickerScope,
    SimilarPickerEntry, StatusFilterEntry, TagPickerTarget, visible_event_entries,
};
use super::timeline_state::{
    TimelineEvent, TimelineFocusLevel, TimelineViewState, cycle_track, timeline_auto_fit,
};
use super::search_results::SearchHit;
use super::splash::{
    StartupError, TYPST_COMPILE_SPINNER, draw_assembly_splash, draw_import_splash,
    draw_pulse_splash, draw_take_extras_splash, draw_typst_compile_splash,
    open_store_with_splash,
};
use super::hjson_edit::{
    set_key_in_hjson_block, set_llm_default_in_hjson,
    set_sound_enabled_in_hjson,
};
use super::inference::{
    AiMode, Inference, InferenceAction, InferenceMode, InferenceStatus, LiftTarget,
};
use super::state::{
    BookStats, ChatSearchState, ChatSelectionState, DeletedParagraphStash,
    ImageCallContext, ImportCounts, Keymap, LinkPickDirection, MoveDir, OpenedDoc,
    SplitView, ViewMdScope,
};
use super::status_helpers::{display_status, next_status, prev_status};
use super::text_utils::{
    PARAGRAPH_PLACEHOLDER_TITLE, body_to_lines, extract_first_sentence,
    format_active_duration, format_age_humantime, pad_or_trim, truncate_to_chars,
};
#[cfg(test)]
use super::text_utils::format_reading_time;

/// 1.2.4+: project-pulse splash shown right after project open.
/// Renders for up to `STARTUP_SPLASH_SECS` seconds or until a key
/// press; the key press is consumed so it doesn't leak into the
/// editor's first frame. Failures (`terminal.draw` returning Err)
/// silently fall through — we'd rather skip the splash than block
/// the editor from launching.
fn run_startup_pulse_splash<B: ratatui::backend::Backend>(
    terminal: &mut Terminal<B>,
    app: &App,
) {
    const STARTUP_SPLASH_SECS: u64 = 7;
    let started = std::time::Instant::now();
    let snap = app.progress_cache.clone();
    let project_display = app
        .layout
        .root
        .file_name()
        .and_then(|s| s.to_str())
        .unwrap_or("project")
        .to_string();
    let total_paragraphs = app
        .hierarchy
        .iter()
        .filter(|n| {
            n.kind == NodeKind::Paragraph
                && !app
                    .hierarchy
                    .ancestors(n)
                    .into_iter()
                    .any(|a| a.kind == NodeKind::Book && a.system_tag.is_some())
        })
        .count();
    let mut by_status: std::collections::BTreeMap<String, usize> =
        std::collections::BTreeMap::new();
    for n in app.hierarchy.iter() {
        if n.kind != NodeKind::Paragraph {
            continue;
        }
        if app
            .hierarchy
            .ancestors(n)
            .into_iter()
            .any(|a| a.kind == NodeKind::Book && a.system_tag.is_some())
        {
            continue;
        }
        let key = n.status.as_deref().unwrap_or("None").to_string();
        *by_status.entry(key).or_insert(0) += 1;
    }

    loop {
        let elapsed = started.elapsed().as_secs();
        if elapsed >= STARTUP_SPLASH_SECS {
            break;
        }
        let remaining = STARTUP_SPLASH_SECS - elapsed;
        let _ = terminal.draw(|f| {
            draw_pulse_splash(
                f,
                &project_display,
                snap.as_ref(),
                total_paragraphs,
                &by_status,
                remaining,
            )
        });
        // Poll for keys with a short timeout. Any keystroke
        // dismisses the splash; consume it so the editor's
        // first frame doesn't see it as input.
        if crossterm::event::poll(std::time::Duration::from_millis(100))
            .unwrap_or(false)
        {
            if let Ok(crossterm::event::Event::Key(_)) = crossterm::event::read() {
                break;
            }
        }
    }
}

pub fn run(project: &Path) -> Result<()> {
    let layout = ProjectLayout::new(project);
    layout.require_initialized().map_err(anyhow::Error::from)?;

    let cfg = Config::load_layered(&layout.config_path()).map_err(anyhow::Error::from)?;

    // 1.2.5+: log the typst engine at startup so users can confirm
    // their HJSON setting took effect. Both engines are always
    // available — the in-process compiler ships in every 1.2.5
    // build — but the default stays `external` to match prior
    // behaviour exactly. The same one-liner also lands on the
    // status bar after first paint, and persistently in the
    // Ctrl+B V credits pane.
    let engine_summary = crate::typst_compile::engine_summary(&cfg);
    tracing::info!(
        target: "inkhaven::typst",
        "typst engine: {engine_summary}",
    );

    // 1.2.15+ Phase R.1 — register the terminal-
    // restore closure with the crash-report panic
    // hook installed in main().  The hook runs the
    // closure before flushing dirty buffers and
    // writing the report so anything printed lands
    // on a cooked terminal.  The previous local
    // panic-hook replacement (raw-mode + alt-screen
    // restore only) is now superseded by the global
    // hook in `crate::crash`.
    crate::crash::set_terminal_restore(Some(Box::new(|| {
        let _ = disable_raw_mode();
        let _ = execute!(io::stdout(), LeaveAlternateScreen);
    })));

    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
    // Try to enable the kitty keyboard protocol. Without it, legacy
    // terminal encoding can't distinguish e.g. `Ctrl+1` from a bare `1`
    // (the TTY only has dedicated bytes for Ctrl+A..Z + a handful of
    // punctuation), so `Ctrl+1` ends up inserting "1" into the AI prompt.
    // Best-effort: terminals that don't support it just ignore the CSI
    // sequence and we run with reduced functionality.
    let kbd_enhanced = execute!(
        io::stdout(),
        PushKeyboardEnhancementFlags(
            KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
                | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS,
        )
    )
    .is_ok();
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    // Open the document store on a worker thread so the TUI can draw a
    // "Please wait" splash immediately. First-time runs of fastembed download
    // a ~120 MB model, which takes long enough to look like a hang otherwise.
    let store_result = open_store_with_splash(&mut terminal, layout.clone(), cfg.clone());

    let store = match store_result {
        Ok(s) => s,
        Err(StartupError::UserAborted) => {
            disable_raw_mode()?;
            execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
            terminal.show_cursor()?;
            return Ok(());
        }
        Err(StartupError::Store(e)) => {
            disable_raw_mode()?;
            execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
            terminal.show_cursor()?;
            return Err(e);
        }
    };

    // 1.2.15+ Phase R.1 — register the project root
    // with the crash-report context now that we know
    // it.  Every subsequent panic includes the project
    // path in the report, and the rescue-buffer flush
    // can resolve rel-paths against it.
    crate::crash::context().set_project(layout.root.clone());
    crate::crash::context().push_action(
        crate::crash::ActionRecord::new("tui.project_opened"),
    );

    // 1.2.15+ Phase H.1 + H.2 — spawn the
    // background health monitor.  `health.enabled`
    // gates the spawn; the monitor returns `None`
    // when disabled and the chip stays hidden.
    // Pinned to the current tokio runtime
    // (inherited from `main.rs`).  Resolves the
    // backup dir the same way `Store::resolve_
    // backup_dir` does — empty `backup.out_dir` →
    // sibling `inkhaven-backups/<basename>/`
    // directory.
    let backup_dir = if cfg.backup.out_dir.is_empty() {
        crate::store::default_user_backup_dir(&layout.root)
    } else {
        let p = std::path::PathBuf::from(&cfg.backup.out_dir);
        if p.is_absolute() {
            p
        } else {
            layout.root.join(&p)
        }
    };
    let health_rx = crate::health::spawn_monitor(
        crate::health::MonitorSetup {
            project_root: layout.root.clone(),
            backup_dir,
            backup_max_age: cfg.backup.max_age,
            backup_amber_threshold: cfg.backup.amber_threshold,
            repair: crate::health::RepairPolicy {
                rescue_orphans: cfg.health.auto_repair.rescue_orphans,
            },
            // 1.2.16+ Phase P.4-pre — Store
            // travels into the monitor task as a
            // cheap clone (Arc-backed inner
            // storage; every public method
            // takes &self).  Deferred P4 checks
            // (DuckDB integrity, HNSW vector
            // parity, etc.) read project state
            // through this handle.
            store: Some(store.clone()),
        },
        cfg.health.enabled,
    );

    // 1.2.6+ — idempotent re-seed of the Prompts book with the
    // embedded `<name>.example` defaults. The seeder skips
    // paragraphs whose title already matches, so existing
    // content is never overwritten. Covers users whose
    // project was initialised before 1.2.6 (and so missed
    // the init-time seeding) — they get the examples the
    // first time they open the project under 1.2.6+. Gated
    // on `ai.reseed_prompt_examples` (default true).
    if cfg.ai.reseed_prompt_examples {
        if let Err(e) = crate::cli::init::seed_prompt_examples(&cfg, &store) {
            tracing::warn!(
                target: "inkhaven::tui::run",
                "could not seed Prompts.book examples on open: {e}",
            );
        }
    }

    // Keep a copy of the config and layout for the auto-backup hook below;
    // App takes ownership of the originals.
    let cfg_for_exit = cfg.clone();
    let layout_for_exit = layout.clone();

    // Scripting layer (policy + active store) was armed inside
    // Store::open via scripting::configure. Force eager Adam
    // construction here so the bootstrap script runs and hook
    // lambdas are registered before the first store mutation
    // can fire a hook.
    if let Err(e) = crate::scripting::init_adam() {
        tracing::warn!("scripting init failed: {e}");
    }

    let mut app = App::new(layout, cfg, store)?;
    // 1.2.15+ Phase H.1 — attach the health-monitor
    // receiver (already spawned above, after the
    // crash context registered the project root).
    app.health_rx = health_rx;
    app.restore_session();
    app.install_progress();

    // 1.2.20+ Phase G — low-disk pre-flight.  Best-effort,
    // one-time: warn in the status line if the project's
    // volume is critically low on free space before a
    // session of edits / a long export.  Atomic writes
    // still fail safely — this is the proactive heads-up.
    if crate::disk::is_low(&app.layout.root, app.cfg.editor.disk_warn_mb) {
        if let Some(avail) = crate::disk::available_bytes(&app.layout.root) {
            app.status = format!(
                "⚠ low disk: only {} free on this volume — free space before a long export",
                crate::disk::human(avail),
            );
        }
    }

    // 1.2.8+ — `editor.mouse_captured = false` in HJSON
    // asks the launcher to start with native terminal
    // drag-select instead of the TUI mouse-capture default.
    // `run` above already enabled capture unconditionally —
    // sync to App's initial state so the two agree before
    // first paint.
    if !app.mouse_captured {
        let _ = execute!(terminal.backend_mut(), DisableMouseCapture);
    }

    // 1.2.4+: project-pulse startup splash. Renders today /
    // streak / active-time / by-status counts; auto-closes
    // after 7 seconds or on any key press. Disabled via
    // `editor.startup_splash = false` in HJSON.
    if app.cfg.editor.startup_splash {
        run_startup_pulse_splash(&mut terminal, &app);
    }

    // 1.2.9+: TTS greeting fires here, after the splash
    // (so the user has seen the daily-progress numbers)
    // and just before the main render loop starts.  Non-
    // blocking — speech plays in parallel with the
    // editor coming up.  No-op when `editor.tts.enabled
    // = false` or the greeting field is empty.
    app.tts_speak_greeting();

    let result = app.run(&mut terminal);

    // 1.2.9+: TTS goodbye fires after the main loop
    // returns but BEFORE shutdown_flush + terminal
    // teardown.  Blocks up to 5 seconds for the audio
    // to drain so the shell doesn't truncate it.
    app.tts_speak_goodbye_blocking();

    // Explicit final flush — HNSW save + DuckDB CHECKPOINT — while the
    // App still holds the Store. The pool's Drop impl would checkpoint
    // implicitly, but doing it explicitly here lets us log any error
    // and guarantees the auto-backup below sees a fully-drained WAL.
    app.shutdown_flush();
    crate::progress::uninstall();

    // Drop the App (and its Store handle) BEFORE running the auto-backup so
    // duckdb/HNSW checkpoint state is flushed to disk and the zip captures
    // a consistent snapshot rather than mid-write WAL data.
    drop(app);

    if let Err(e) = maybe_auto_backup(&mut terminal, &layout_for_exit, &cfg_for_exit) {
        // Backup failures must not eat the editor's own exit status — just
        // log them to stderr (which routes to .inkhaven.log in TUI mode)
        // and let the user retry with `inkhaven backup` manually.
        tracing::warn!("auto-backup on exit failed: {e}");
    }

    if kbd_enhanced {
        let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
    }
    let _ = execute!(terminal.backend_mut(), DisableMouseCapture);
    disable_raw_mode()?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
    terminal.show_cursor()?;
    // 1.2.15+ Phase R.1 — clear the terminal-restore
    // closure now that the terminal is back to cooked
    // mode.  A panic from anything that runs after
    // this point (CLI cleanup, error printing, etc.)
    // still hits the crash-report hook installed in
    // main(), but doesn't try to "restore" a terminal
    // that's already cooked.
    crate::crash::set_terminal_restore(None);

    result
}

/// Walk `root` and count regular files that would be imported as
/// paragraphs (mirrors the importer's hidden-entry filter so the total
/// matches the progress callbacks).
/// 1.2.16+ Phase A.5 — count Characters / Places /
/// Artefacts entries.  Used by the worldbuilding
/// glossary chip in the status bar.  Returns
/// `(chars, places, artefacts)`.
pub(crate) fn glossary_counts(
    hierarchy: &crate::store::hierarchy::Hierarchy,
) -> (usize, usize, usize) {
    use crate::store::{
        NodeKind, SYSTEM_TAG_ARTEFACTS, SYSTEM_TAG_CHARACTERS, SYSTEM_TAG_PLACES,
    };
    let count_in = |tag: &str| -> usize {
        let Some(book) = hierarchy.iter().find(|n| {
            n.kind == NodeKind::Book && n.system_tag.as_deref() == Some(tag)
        }) else {
            return 0;
        };
        hierarchy
            .collect_subtree(book.id)
            .into_iter()
            .filter_map(|id| hierarchy.get(id))
            .filter(|n| n.kind == NodeKind::Paragraph)
            .count()
    };
    (
        count_in(SYSTEM_TAG_CHARACTERS),
        count_in(SYSTEM_TAG_PLACES),
        count_in(SYSTEM_TAG_ARTEFACTS),
    )
}

fn count_importable_files(root: &Path) -> usize {
    walkdir::WalkDir::new(root)
        .follow_links(false)
        .into_iter()
        .filter_entry(|e| {
            e.file_name()
                .to_str()
                .map(|s| !s.starts_with('.'))
                .unwrap_or(true)
        })
        .filter_map(Result::ok)
        .filter(|e| e.file_type().is_file())
        .count()
}

/// Read a directory's immediate children, filter hidden entries, sort dirs
/// first then alphabetical. Returns owned paths so the caller doesn't carry
/// a borrow against the DirEntry iterator.
fn read_sorted_children(source: &std::path::Path) -> Vec<std::path::PathBuf> {
    let Ok(rd) = std::fs::read_dir(source) else {
        return Vec::new();
    };
    let mut entries: Vec<_> = rd
        .filter_map(Result::ok)
        .filter(|e| {
            e.file_name()
                .to_str()
                .map(|s| !s.starts_with('.'))
                .unwrap_or(true)
        })
        .collect();
    entries.sort_by(|a, b| {
        let a_dir = a.path().is_dir();
        let b_dir = b.path().is_dir();
        match (a_dir, b_dir) {
            (true, false) => std::cmp::Ordering::Less,
            (false, true) => std::cmp::Ordering::Greater,
            _ => a.file_name().cmp(&b.file_name()),
        }
    });
    entries.into_iter().map(|e| e.path()).collect()
}

fn derive_paragraph_title_from_path(path: &std::path::Path) -> String {
    let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("imported");
    let pretty: String = stem
        .replace('_', " ")
        .replace('-', " ")
        .split_whitespace()
        .map(|w| {
            let mut c = w.chars();
            match c.next() {
                None => String::new(),
                Some(first) => first.to_uppercase().chain(c).collect(),
            }
        })
        .collect::<Vec<_>>()
        .join(" ");
    if pretty.is_empty() {
        "Imported".into()
    } else {
        pretty
    }
}

/// Return the lowercase extension if `path` looks like an image file
/// we'll route to `import_single_image`. The list is the recognised
/// set Typst's `#image(...)` natively understands: PNG, JPG, JPEG,
/// GIF, WebP, SVG. Anything else stays a paragraph candidate.
pub(super) fn image_extension_for(path: &std::path::Path) -> Option<String> {
    let ext = path.extension()?.to_str()?.to_lowercase();
    match ext.as_str() {
        "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" => Some(ext),
        _ => None,
    }
}

/// Map file extension → content_type tag stored on the resulting
/// Paragraph. `.hjson` → "hjson"; anything else (including `.typ` /
/// no extension / plain text files) → `None`, which means "typst
/// default".
fn content_type_for(path: &std::path::Path) -> Option<String> {
    let ext = path.extension()?.to_str()?.to_lowercase();
    match ext.as_str() {
        "hjson" => Some("hjson".into()),
        _ => None,
    }
}

/// 1.2.6+ — pick the "thing to paste" for a Replace-style AI
/// apply. For grammar-style outputs (`<<<CORRECTED>>> … <<<END>>>`
/// markers, a trailing fenced code block, or a "Corrected …"
/// section header), this returns only that block — the user
/// doesn't want the commentary, summary, or diff explanation
/// the model wrote around it. For everything else, it falls
/// back to the full response (markdown→typst converted by the
/// caller). Returns `(text, extracted)` so the caller can
/// surface a "✂ extracted X of Y chars" hint in the status
/// line.
///
/// `force_extract` (used by ReplaceCorrected / F7-apply) means
/// "if I can't find a discrete block, treat that as an error";
/// the caller should refuse to apply rather than paste prose
/// commentary by mistake.
pub(super) fn select_apply_text(
    raw: &str,
    force_extract: bool,
) -> Result<(String, bool), &'static str> {
    if let Some(extracted) = extract_corrected_text(raw) {
        return Ok((extracted, true));
    }
    // Diagnostic log when no extractable block is found.
    // Surfaces the first 200 chars of the response so a user
    // who hits this can paste their `.inkhaven.log` and we
    // see exactly which bracket shape the model emitted.
    tracing::warn!(
        target: "inkhaven::ai::apply",
        force_extract = force_extract,
        sample_len = raw.len(),
        sample_head = %raw.chars().take(200).collect::<String>(),
        "select_apply_text: no extractable corrected block — \
         marker / Unicode-bracket / code-fence / heading passes all missed",
    );
    if force_extract {
        return Err(
            "couldn't find a corrected block in the response \
             (expected `<<<CORRECTED>>>` markers, a fenced code \
             block, or a `Corrected:` heading)",
        );
    }
    // Non-grammar Replace path: convert markdown→Typst on the
    // whole response. The conversion is best-effort; passes
    // through unrecognised markup verbatim.
    Ok((super::markdown::markdown_to_typst(raw), false))
}

/// Extract only the corrected-paragraph text from a grammar-check
/// response. Tries in order:
///
///   1. Canonical `<<<CORRECTED>>>` / `<<<END>>>` marker block
///      (what the system prompt instructs).
///   2. Relaxed bracket pair — any two-or-more `<` followed by
///      optional word characters followed by two-or-more `>`
///      appearing at least twice. Models routinely compress
///      the canonical markers down to `<<>>` / `<<END>>` /
///      `<<<corrected>>>`, even when the prompt is explicit;
///      this catches every variant we've observed in deepseek,
///      gemini, and gpt-4o-mini drift.
///   3. Last fenced code block — common when the model
///      ignores markers entirely.
///   4. Everything after a "Corrected …" line.
///
/// Returns `None` if none of those patterns match so callers
/// can refuse rather than paste commentary by mistake.
/// 1.2.13+ Phase C.2 — extract only the
/// `<<<TRANSLATION>>> … <<<END>>>` block from a translation
/// response.  Used by the Insert apply path when
/// `pending_translation` is set so the manuscript receives
/// only the target-language prose, not the gloss table or
/// applied-rules list that surround it.  Returns `None`
/// when the markers are missing — caller falls back to
/// pasting the full response so the user sees something
/// rather than silent dropping.
pub(super) fn extract_translation_text(response: &str) -> Option<String> {
    let begin = response.find(TRANSLATION_BEGIN)?;
    let after = &response[begin + TRANSLATION_BEGIN.len()..];
    let end_offset = after.find(CORRECTED_END)?;
    let inner = &after[..end_offset];
    let cleaned =
        inner.trim_matches(|c: char| c == '\n' || c == '\r' || c == ' ');
    if cleaned.is_empty() {
        None
    } else {
        Some(cleaned.to_string())
    }
}

pub(super) fn extract_corrected_text(response: &str) -> Option<String> {
    if let Some(begin) = response.find(CORRECTED_BEGIN) {
        let after = &response[begin + CORRECTED_BEGIN.len()..];
        if let Some(end_offset) = after.find(CORRECTED_END) {
            let inner = &after[..end_offset];
            let cleaned = inner.trim_matches(|c: char| c == '\n' || c == '\r' || c == ' ');
            if !cleaned.is_empty() {
                return Some(cleaned.to_string());
            }
        }
    }
    // Pass A — ASCII multi-char marker (`<<>>`, `<<END>>`,
    // `<<<corrected>>>`, etc). Token shape: 2+ `<`, optional
    // word chars, 2+ `>`. If we find at least two such tokens,
    // content between the first and the last is the correction.
    let ascii_re = regex::Regex::new(r"<<+[A-Za-z_]*>>+");
    if let Ok(re) = ascii_re {
        let positions: Vec<_> = re.find_iter(response).collect();
        if positions.len() >= 2 {
            let first = &positions[0];
            let last = &positions[positions.len() - 1];
            if last.start() > first.end() {
                let inner = &response[first.end()..last.start()];
                let cleaned = inner
                    .trim_matches(|c: char| c == '\n' || c == '\r' || c == ' ');
                if !cleaned.is_empty() {
                    return Some(cleaned.to_string());
                }
            }
        }
    }
    // Pass B — single-char Unicode bracket pairs. Several
    // shapes show up depending on the model's tokenizer:
    //
    //   ≪≫        U+226A / U+226B (much-less / greater)
    //   «»        U+00AB / U+00BB (single guillemets — render
    //             in monospace fonts as a tight double-angle
    //             glyph that visually mimics ASCII `<<>>`)
    //   〈〉⟨⟩《》  CJK / mathematical angle brackets
    //
    // Try labeled first (`«CORRECTED»…«END»`), then unlabeled
    // (`« body »`). Labeled must precede unlabeled because an
    // unlabeled scan over a labeled response would grab
    // `CORRECTED» body «END` (including the inner brackets).
    let pairs: &[(char, char)] = &[
        ('≪', '≫'),
        ('«', '»'),
        ('〈', '〉'),
        ('⟨', '⟩'),
        ('《', '》'),
    ];
    // Pass B.1 — labeled Unicode markers.
    for &(l, r) in pairs {
        let pat = format!(r"{}[A-Za-z_]+{}", regex::escape(&l.to_string()), regex::escape(&r.to_string()));
        let Ok(re) = regex::Regex::new(&pat) else {
            continue;
        };
        let positions: Vec<_> = re.find_iter(response).collect();
        if positions.len() >= 2 {
            let first = &positions[0];
            let last = &positions[positions.len() - 1];
            if last.start() > first.end() {
                let inner = &response[first.end()..last.start()];
                let cleaned = inner
                    .trim_matches(|c: char| c == '\n' || c == '\r' || c == ' ');
                if !cleaned.is_empty() {
                    return Some(cleaned.to_string());
                }
            }
        }
    }
    // Pass B.2 — unlabeled Unicode markers (`« body »`).
    // First `l` and the LAST `r` after it, content between.
    for &(l, r) in pairs {
        let Some(first_left) = response.find(l) else {
            continue;
        };
        let inner_search_start = first_left + l.len_utf8();
        let Some(last_right_rel) =
            response[inner_search_start..].rfind(r)
        else {
            continue;
        };
        let last_right = inner_search_start + last_right_rel;
        if last_right <= first_left + l.len_utf8() {
            continue;
        }
        let inner = &response[first_left + l.len_utf8()..last_right];
        let cleaned = inner
            .trim_matches(|c: char| c == '\n' || c == '\r' || c == ' ');
        if !cleaned.is_empty() {
            return Some(cleaned.to_string());
        }
    }
    if let Some(last_close) = response.rfind("```") {
        let before = &response[..last_close];
        if let Some(open) = before.rfind("```") {
            let body = &response[open + 3..last_close];
            let (first_nl, rest) = match body.find('\n') {
                Some(i) => (&body[..i], &body[i + 1..]),
                None => (body, ""),
            };
            // Drop a short alphanumeric language tag on the first line.
            let cleaned = if !first_nl.is_empty()
                && first_nl.len() < 16
                && first_nl.chars().all(|c| c.is_ascii_alphanumeric())
            {
                rest.to_string()
            } else {
                body.to_string()
            };
            let trimmed = cleaned.trim_matches(|c: char| c == '\n' || c == '\r' || c == ' ');
            if !trimmed.is_empty() {
                return Some(trimmed.to_string());
            }
        }
    }
    let lower = response.to_ascii_lowercase();
    if let Some(idx) = lower.rfind("corrected") {
        if let Some(line_end) = response[idx..].find('\n') {
            let after = response[idx + line_end + 1..]
                .trim_matches(|c: char| c == '\n' || c == '\r' || c == ' ');
            if !after.is_empty() {
                return Some(after.to_string());
            }
        }
    }
    None
}

#[cfg(test)]
mod corrected_tests {
    use super::*;

    #[test]
    fn marker_block_wins() {
        let r = "Summary: 1 issue.\n\n<<<CORRECTED>>>\n= Heading\n\nThe rain in Spain.\n<<<END>>>\n";
        let got = extract_corrected_text(r).unwrap();
        assert_eq!(got, "= Heading\n\nThe rain in Spain.");
    }

    #[test]
    fn falls_back_to_code_fence() {
        let r = "Summary.\n\n```typst\n= Heading\n\nFixed body.\n```\n";
        let got = extract_corrected_text(r).unwrap();
        assert_eq!(got, "= Heading\n\nFixed body.");
    }

    #[test]
    fn falls_back_to_corrected_heading() {
        let r = "Summary line.\n- issue 1\n\nCorrected paragraph:\n= Heading\n\nFixed body.\n";
        let got = extract_corrected_text(r).unwrap();
        assert_eq!(got, "= Heading\n\nFixed body.");
    }

    #[test]
    fn returns_none_on_empty_or_unmatched() {
        assert!(extract_corrected_text("").is_none());
        assert!(extract_corrected_text("Just commentary, no markers.").is_none());
    }

    /// 1.2.6+ — models routinely compress the canonical
    /// markers. We've observed `<<>>` (deepseek), `<<END>>`,
    /// and `<<<corrected>>>` (lowercase). All should land on
    /// the relaxed-bracket pass.

    #[test]
    fn relaxed_empty_brackets_deepseek_drift() {
        // Exact shape from a deepseek grammar-check reply.
        let r = "\
2 grammar issues, otherwise clean

+ \"lile\" → \"little\"
+ \"playes\" → \"plays\"
+ \"tha\" → \"the\"

<<>> The little boy plays the fiddle. <<>>
";
        let got = extract_corrected_text(r).unwrap();
        assert_eq!(got, "The little boy plays the fiddle.");
    }

    #[test]
    fn relaxed_double_bracket_with_label() {
        let r = "Summary.\n\n<<CORRECTED>>\n= H\nBody.\n<<END>>\n";
        let got = extract_corrected_text(r).unwrap();
        assert_eq!(got, "= H\nBody.");
    }

    #[test]
    fn relaxed_lowercase_canonical() {
        let r = "Summary.\n<<<corrected>>>\nBody.\n<<<end>>>\n";
        let got = extract_corrected_text(r).unwrap();
        assert_eq!(got, "Body.");
    }

    /// U+226A / U+226B "much less / greater than" — a single
    /// Unicode char on each side that renders in monospace
    /// fonts as a tight double-angle glyph. Observed when the
    /// model's tokenizer collapses `<<` into a single
    /// codepoint.
    #[test]
    fn relaxed_unicode_much_less_greater() {
        let r = "Summary.\n\n≪ The corrected sentence. ≫\n";
        let got = extract_corrected_text(r).unwrap();
        assert_eq!(got, "The corrected sentence.");
    }

    /// U+00AB / U+00BB single guillemets. Same visual effect
    /// as the much-less-than pair when the font renders them
    /// compact.
    #[test]
    fn relaxed_unicode_guillemets() {
        let r = "Summary.\n\n«CORRECTED» Body sentence. «END»\n";
        let got = extract_corrected_text(r).unwrap();
        assert_eq!(got, "Body sentence.");
    }

    /// Multi-line response from the screenshot — with a
    /// numbered issue list, a Unicode arrow, and the deepseek
    /// `<<>>` brackets at the end. End-to-end smoke.
    #[test]
    fn relaxed_full_screenshot_shape() {
        let r = "1 grammar issue, 1 spelling issue, otherwise clean.\n\n\
                 Issues:\n\
                 1. \"litle\" should be \"little\"\n\
                 2. \"playz\" should be \"plays\"\n\
                 3. (Lowercase \"tha\" → \"the\".)\n\n\
                 <<>> The little boy plays the fiddle. <<>>\n";
        let got = extract_corrected_text(r).unwrap();
        assert_eq!(got, "The little boy plays the fiddle.");
    }

    // 1.2.13+ Phase C.2 — translation extractor.  Lifts
    // the `<<<TRANSLATION>>> … <<<END>>>` block.

    #[test]
    fn translation_extracts_marker_block() {
        let r = "1. The translation.\n\n\
                 <<<TRANSLATION>>>\n\
                 Aiya, ñ'olor!\n\
                 <<<END>>>\n\n\
                 2. Gloss table…";
        let got = extract_translation_text(r).unwrap();
        assert_eq!(got, "Aiya, ñ'olor!");
    }

    #[test]
    fn translation_extracts_multiline_prose() {
        let r = "Intro.\n\n<<<TRANSLATION>>>\n\
                 Line one.\n\
                 Line two of the invented language.\n\
                 <<<END>>>\nGloss.";
        let got = extract_translation_text(r).unwrap();
        assert_eq!(got, "Line one.\nLine two of the invented language.");
    }

    #[test]
    fn translation_returns_none_when_markers_missing() {
        assert!(extract_translation_text("").is_none());
        assert!(extract_translation_text(
            "Just the translation prose, no markers."
        )
        .is_none());
    }

    #[test]
    fn translation_returns_none_on_empty_block() {
        let r = "<<<TRANSLATION>>>\n\n<<<END>>>\n";
        assert!(extract_translation_text(r).is_none());
    }

    // 1.2.6+ — `select_apply_text` should auto-prefer a
    // discrete corrected block over the surrounding chatter,
    // regardless of which apply action triggered.

    #[test]
    fn select_extracts_marker_block_when_force_false() {
        let r = "I found 2 issues.\n- foo\n- bar\n\n<<<CORRECTED>>>\n= H\nBody.\n<<<END>>>\n";
        let (text, extracted) = select_apply_text(r, false).unwrap();
        assert!(extracted);
        assert_eq!(text, "= H\nBody.");
    }

    #[test]
    fn select_extracts_code_fence_when_force_false() {
        let r = "Here is the rewrite:\n\n```typst\n= H\nBody.\n```\nThoughts above.";
        let (text, extracted) = select_apply_text(r, false).unwrap();
        assert!(extracted);
        assert_eq!(text, "= H\nBody.");
    }

    #[test]
    fn select_falls_back_to_full_markdown_when_no_extractable_block() {
        let r = "Some plain commentary without a discrete block.";
        let (text, extracted) = select_apply_text(r, false).unwrap();
        assert!(!extracted);
        assert!(text.contains("commentary"));
    }

    #[test]
    fn select_force_errors_on_unextractable() {
        let r = "Just commentary, no markers anywhere.";
        assert!(select_apply_text(r, true).is_err());
    }
}

#[cfg(test)]
mod book_info_tests {
    use super::*;

    #[test]
    fn count_sentences_basic_terminators() {
        assert_eq!(count_sentences("Hello. World!"), 2);
        assert_eq!(count_sentences("Why? Because!"), 2);
        assert_eq!(count_sentences("One sentence only"), 0);
    }

    #[test]
    fn count_sentences_collapses_runs() {
        // Repeated terminators ("...", "?!") should count as one sentence
        // each, not three / two.
        assert_eq!(count_sentences("Hmm... interesting?!"), 2);
    }

    #[test]
    fn count_sentences_ignores_headings_and_comments() {
        let body = "= Chapter title\n\n// this comment has a period.\n\
                    First. Second.";
        assert_eq!(count_sentences(body), 2);
    }

    #[test]
    fn count_sentences_handles_blank_and_multiline() {
        let body = "First sentence.\n\nSecond sentence!\nThird?";
        assert_eq!(count_sentences(body), 3);
    }

    #[test]
    fn format_age_subminute() {
        assert_eq!(format_age_humantime(std::time::Duration::from_secs(0)), "0s");
        assert_eq!(format_age_humantime(std::time::Duration::from_secs(45)), "45s");
    }

    #[test]
    fn chat_turn_roundtrips_through_serde() {
        let history = vec![
            ChatTurn::User("What's the weather?".into()),
            ChatTurn::Assistant("I don't have a weather tool.".into()),
        ];
        let json = serde_json::to_string(&history).expect("encode");
        let back: Vec<ChatTurn> = serde_json::from_str(&json).expect("decode");
        assert_eq!(back.len(), 2);
        match &back[0] {
            ChatTurn::User(s) => assert_eq!(s, "What's the weather?"),
            _ => panic!("expected User turn"),
        }
        match &back[1] {
            ChatTurn::Assistant(s) => assert_eq!(s, "I don't have a weather tool."),
            _ => panic!("expected Assistant turn"),
        }
    }

    #[test]
    fn byte_offset_for_cursor_basic() {
        let src = "abc\ndef\nghi";
        // Row 0, col 0 → byte 0.
        assert_eq!(byte_offset_for_cursor(src, 0, 0), 0);
        // Row 0, col 3 → end of "abc".
        assert_eq!(byte_offset_for_cursor(src, 0, 3), 3);
        // Row 1, col 0 → after the first newline.
        assert_eq!(byte_offset_for_cursor(src, 1, 0), 4);
        // Row 2, col 2 → "gh|i".
        assert_eq!(byte_offset_for_cursor(src, 2, 2), 10);
    }

    #[test]
    fn byte_offset_for_cursor_handles_multibyte() {
        // "Москва" is 6 chars / 12 bytes.
        let src = "Москва";
        assert_eq!(byte_offset_for_cursor(src, 0, 0), 0);
        assert_eq!(byte_offset_for_cursor(src, 0, 6), 12);
        // Mid-way: 3 chars in is 6 bytes.
        assert_eq!(byte_offset_for_cursor(src, 0, 3), 6);
    }

    #[test]
    fn open_pair_for_known_openers() {
        assert_eq!(open_pair_for('('), Some(')'));
        assert_eq!(open_pair_for('['), Some(']'));
        assert_eq!(open_pair_for('{'), Some('}'));
        assert_eq!(open_pair_for('"'), Some('"'));
        assert_eq!(open_pair_for('\''), Some('\''));
        assert_eq!(open_pair_for('a'), None);
        assert_eq!(open_pair_for(')'), None);
    }

    #[test]
    fn is_close_pair_char_covers_all_closers() {
        for c in [')', ']', '}', '"', '\''] {
            assert!(is_close_pair_char(c), "{c} should be a close pair char");
        }
        for c in ['(', '[', '{', 'a', '#'] {
            assert!(!is_close_pair_char(c), "{c} should not be");
        }
    }

    /// Reproduce the pair-detection rule used by the Enter and
    /// Backspace handlers without going through tui-textarea.
    fn between_pair(line: &str, col: usize) -> bool {
        let chars: Vec<char> = line.chars().collect();
        let before = if col > 0 { chars.get(col - 1).copied() } else { None };
        let after = chars.get(col).copied();
        matches!(
            (before, after),
            (Some('('), Some(')')) | (Some('['), Some(']')) | (Some('{'), Some('}'))
        )
    }

    #[test]
    fn pair_detection_inside_freshly_typed_pair() {
        // `foo(|)` — cursor at col 4, between `(` and `)`.
        assert!(between_pair("foo()", 4));
        // `[]` — cursor at col 1, between `[` and `]`.
        assert!(between_pair("[]", 1));
        // `{}` — cursor at col 1, between `{` and `}`.
        assert!(between_pair("{}", 1));
    }

    #[test]
    fn pair_detection_skips_when_chars_dont_match() {
        // Just before `)` but `(` is not on the immediate left.
        assert!(!between_pair("foo )", 4));
        // Indented prose with parens that DO match — pair logic only
        // cares about the immediately adjacent chars.
        assert!(!between_pair("  abc(def)", 6));
    }

    #[test]
    fn filter_functions_empty_returns_all() {
        let all = filter_functions("").len();
        assert!(all > 50, "expected the baked-in table to have >50 entries, got {all}");
    }

    #[test]
    fn filter_functions_substring_match() {
        let m: Vec<&'static str> = filter_functions("image").iter().map(|f| f.name).collect();
        assert!(m.contains(&"image"), "got: {m:?}");
        assert!(m.contains(&"figure") == false || true);
    }

    #[test]
    fn filter_functions_case_insensitive() {
        let a = filter_functions("Heading");
        let b = filter_functions("heading");
        assert_eq!(a.len(), b.len());
    }

    #[test]
    fn filter_functions_no_match_returns_empty() {
        assert!(filter_functions("zzz-no-such-function").is_empty());
    }

    #[test]
    fn image_call_context_inside_open_string() {
        // Cursor is after the `"` — we're inside the first string arg.
        let line = "#image(\"";
        let ctx = detect_image_call_context(line, line.chars().count())
            .expect("should detect");
        assert!(!ctx.closing_quote_present);
    }

    #[test]
    fn image_call_context_inside_with_closing_quote() {
        let line = "#image(\"\")";
        // Cursor positioned right after the opening quote (col 8).
        let ctx = detect_image_call_context(line, 8).expect("should detect");
        assert!(ctx.closing_quote_present);
    }

    #[test]
    fn image_call_context_not_inside_when_closed() {
        // After the closing `)`.
        let line = "#image(\"cover.png\")";
        let n = line.chars().count();
        assert!(detect_image_call_context(line, n).is_none());
    }

    #[test]
    fn image_call_context_requires_hash_prefix() {
        // `image(` without leading `#` is not a typst function call.
        let line = "image(\"cover.png";
        assert!(detect_image_call_context(line, line.chars().count()).is_none());
    }

    #[test]
    fn image_call_context_not_after_other_function() {
        let line = "#text(\"hello";
        assert!(detect_image_call_context(line, line.chars().count()).is_none());
    }

    #[test]
    fn image_call_context_in_chapter_body() {
        // Realistic editor line — leading indentation + prose, then a
        // `#image(` partway through.
        let line = "  Some prose. #image(\"01-co";
        let ctx = detect_image_call_context(line, line.chars().count())
            .expect("should detect inside the call");
        assert!(!ctx.closing_quote_present);
    }

    #[test]
    fn body_to_lines_strips_crlf() {
        // CRLF (DOS / Windows / RFC dumps): trailing `\r` must not
        // survive into the line list.
        let body = "Network Working Group\r\nRequest for Comments: 1\r\n";
        let lines = body_to_lines(body);
        assert_eq!(lines.len(), 3); // last `\n` produces a trailing "" entry
        assert_eq!(lines[0], "Network Working Group");
        assert_eq!(lines[1], "Request for Comments: 1");
        assert_eq!(lines[2], "");
    }

    #[test]
    fn body_to_lines_strips_bare_cr() {
        // Old-Mac files used bare `\r`. Treat them as line breaks too.
        let body = "first\rsecond\rthird";
        let lines = body_to_lines(body);
        assert_eq!(lines, vec!["first", "second", "third"]);
    }

    #[test]
    fn body_to_lines_unix_passthrough() {
        let body = "alpha\nbeta\ngamma";
        let lines = body_to_lines(body);
        assert_eq!(lines, vec!["alpha", "beta", "gamma"]);
    }

    #[test]
    fn body_to_lines_empty_yields_single_empty() {
        assert_eq!(body_to_lines(""), vec![String::new()]);
    }

    #[test]
    fn set_llm_default_rewrites_value_only() {
        let raw = "\
language: english

llm: {
  // Provider used by default.
  default: gemini
  providers: {
    gemini: { model: gemini-2.5-pro }
    ollama: { model: llama3.2 }
  }
}
";
        let out = set_llm_default_in_hjson(raw, "ollama").unwrap();
        // The default line changed.
        assert!(out.contains("default: ollama"));
        assert!(!out.contains("default: gemini"));
        // Everything else survives byte-for-byte.
        assert!(out.contains("language: english"));
        assert!(out.contains("// Provider used by default."));
        assert!(out.contains("model: gemini-2.5-pro"));
        assert!(out.contains("model: llama3.2"));
    }

    #[test]
    fn set_llm_default_preserves_trailing_comment() {
        let raw = "\
llm: {
  default: gemini    // pick gemini for prose
  providers: { gemini: { model: x } }
}
";
        let out = set_llm_default_in_hjson(raw, "ollama").unwrap();
        // Value swapped, comment retained.
        assert!(
            out.contains("default: ollama"),
            "expected new default; got:\n{out}"
        );
        assert!(
            out.contains("// pick gemini for prose"),
            "expected trailing comment preserved; got:\n{out}"
        );
    }

    #[test]
    fn set_llm_default_quotes_unsafe_values() {
        let raw = "\
llm: {
  default: gemini
  providers: { x: { model: y } }
}
";
        let out = set_llm_default_in_hjson(raw, "weird name").unwrap();
        assert!(
            out.contains("default: \"weird name\""),
            "value with space should be quoted; got:\n{out}"
        );
    }

    #[test]
    fn set_llm_default_inserts_when_missing() {
        // No `default:` key in the llm block — insert one.
        let raw = "\
llm: {
  providers: { gemini: { model: g } }
}
";
        let out = set_llm_default_in_hjson(raw, "gemini").unwrap();
        assert!(
            out.contains("default: gemini"),
            "expected inserted default; got:\n{out}"
        );
        // The providers block survives.
        assert!(out.contains("providers: { gemini: { model: g } }"));
    }

    #[test]
    fn set_llm_default_errors_on_missing_block() {
        let raw = "language: english\n";
        let err = set_llm_default_in_hjson(raw, "gemini").unwrap_err();
        assert!(err.contains("no `llm:` block"), "got: {err}");
    }

    #[test]
    fn set_llm_default_roundtrips_shipped_template() {
        // The annotated default HJSON the project ships with has to
        // survive a switch and still parse cleanly via `Config::load`.
        // This is the regression we care about most — if the in-place
        // edit garbles the file, the user's next launch fails to read
        // their config.
        let raw = crate::config::DEFAULT_PROJECT_CONFIG;
        let edited = set_llm_default_in_hjson(raw, "ollama").unwrap();
        let cfg: crate::config::Config =
            serde_hjson::from_str(&edited).expect("edited HJSON should still parse");
        assert_eq!(cfg.llm.default, "ollama");
        // The two non-llm sections should round-trip unchanged.
        assert_eq!(cfg.language, "english");
        assert_eq!(cfg.editor.tab_width, 2);
        // Comments survive (string match in the raw text).
        assert!(edited.contains("// Provider used by the AI pane"));
    }

    #[test]
    fn set_sound_enabled_rewrites_existing_block() {
        let raw = "\
sound: {
  enabled: false
  volume: 0.6
}
";
        let out = set_sound_enabled_in_hjson(raw, true).unwrap();
        assert!(out.contains("enabled: true"));
        assert!(out.contains("volume: 0.6"));
        // And toggling back.
        let back = set_sound_enabled_in_hjson(&out, false).unwrap();
        assert!(back.contains("enabled: false"));
    }

    #[test]
    fn set_sound_enabled_roundtrips_shipped_template() {
        let raw = crate::config::DEFAULT_PROJECT_CONFIG;
        let edited = set_sound_enabled_in_hjson(raw, true).unwrap();
        let cfg: crate::config::Config =
            serde_hjson::from_str(&edited).expect("edited HJSON should still parse");
        assert!(cfg.sound.enabled);
        // Sound-unrelated stanzas untouched.
        assert_eq!(cfg.language, "english");
        assert!(edited.contains("// Typewriter-style sound effects"));
    }

    #[test]
    fn set_sound_enabled_inserts_block_when_missing() {
        // Older configs without a sound block — the helper inserts a
        // minimal one just inside the root closing brace, so the file
        // stays valid HJSON.
        let raw = "{\n  language: english\n}\n";
        let out = set_sound_enabled_in_hjson(raw, true).unwrap();
        assert!(out.contains("sound: {"), "got:\n{out}");
        assert!(out.contains("enabled: true"));
        let cfg: crate::config::Config =
            serde_hjson::from_str(&out).expect("inserted HJSON should still parse");
        assert!(cfg.sound.enabled);
    }

    #[test]
    fn set_sound_enabled_insertion_lands_before_root_close() {
        // Regression: the previous version appended after the root `}`
        // which made the file invalid HJSON. Verify the new block lives
        // strictly before the root close, and the file round-trips.
        let raw = "{\n  language: english\n  theme: {\n    pane_bg: \"#1e1e2e\"\n  }\n}\n";
        let out = set_sound_enabled_in_hjson(raw, true).unwrap();
        let sound_idx = out.find("sound:").expect("sound block inserted");
        let last_close = out.rfind('}').expect("root close present");
        assert!(
            sound_idx < last_close,
            "sound block must be before root close — got:\n{out}"
        );
        let _: crate::config::Config = serde_hjson::from_str(&out).expect("must parse");
    }

    #[test]
    fn format_reading_time_thresholds() {
        assert_eq!(format_reading_time(0), "<1m");
        assert_eq!(format_reading_time(1), "~1m");          // ceil(1/250) = 1
        assert_eq!(format_reading_time(250), "~1m");
        assert_eq!(format_reading_time(251), "~2m");
        assert_eq!(format_reading_time(250 * 60), "~1h");   // exact hour, no "0m"
        assert_eq!(format_reading_time(250 * 75), "~1h 15m");
    }

    #[test]
    fn set_llm_default_does_not_match_provider_internals() {
        // A `default:` key inside a nested provider block must NOT be
        // mistaken for `llm.default`. Our scanner requires depth==1.
        let raw = "\
llm: {
  default: gemini
  providers: {
    fake: {
      default: should_not_be_touched
      model: x
    }
  }
}
";
        let out = set_llm_default_in_hjson(raw, "ollama").unwrap();
        assert!(out.contains("default: ollama"));
        assert!(
            out.contains("default: should_not_be_touched"),
            "nested `default:` inside a provider must survive untouched; got:\n{out}"
        );
    }

    #[test]
    fn format_age_minutes_hours_days() {
        assert_eq!(
            format_age_humantime(std::time::Duration::from_secs(7 * 60)),
            "7m"
        );
        assert_eq!(
            format_age_humantime(std::time::Duration::from_secs(3 * 3600 + 30 * 60)),
            "3h 30m"
        );
        assert_eq!(
            format_age_humantime(std::time::Duration::from_secs(2 * 86_400 + 4 * 3600)),
            "2d 4h"
        );
        // Whole-hour and whole-day values shouldn't dangle a "0m" / "0h".
        assert_eq!(
            format_age_humantime(std::time::Duration::from_secs(5 * 86_400)),
            "5d"
        );
        assert_eq!(
            format_age_humantime(std::time::Duration::from_secs(2 * 3600)),
            "2h"
        );
    }
}

/// Strip a leading Typst heading line (`= Title`) from a paragraph body so
/// it doesn't end up in the LLM prompt. The heading is editor chrome — it
/// describes the prompt for tree-pane navigation, not text the user wants
/// to send. Trims any blank lines that immediately follow the heading.
fn strip_leading_typst_heading(body: &str) -> String {
    let mut lines: Vec<&str> = body.lines().collect();
    if let Some(first) = lines.first() {
        if first.trim_start().starts_with('=') {
            lines.remove(0);
            while lines.first().is_some_and(|l| l.trim().is_empty()) {
                lines.remove(0);
            }
        }
    }
    lines.join("\n")
}

/// 1.2.8+ — maximum number of recently-deleted paragraphs held
/// in `App.kill_ring`. Picked to be large enough that an
/// accidental burst of single-¶ deletes doesn't clobber an
/// earlier recovery, small enough that the picker fits on a
/// 24-row terminal without scrolling.
const KILL_RING_CAP: usize = 10;

// `is_scene_break` lives in `crate::manuscript` (imported
// above) — one definition shared by the editor's scene-
// break navigation and the manuscript exporter.

/// 1.2.9+ — sanitise a paragraph title into a
/// filesystem-safe slug for the default audio path.
/// Lowercased, ASCII-alphanumeric kept verbatim, every
/// other char collapsed to a hyphen, runs of hyphens
/// trimmed, empty result falls back to "paragraph".
/// Cyrillic / non-Latin chars survive as hyphens
/// because filesystem-portability is the goal here;
/// users who want a localised name edit the path
/// before pressing Enter.
fn audio_filename_slug(title: &str) -> String {
    let lc = title.to_lowercase();
    let mut out = String::with_capacity(lc.len());
    let mut last_was_dash = false;
    for c in lc.chars() {
        if c.is_ascii_alphanumeric() {
            out.push(c);
            last_was_dash = false;
        } else {
            if !last_was_dash && !out.is_empty() {
                out.push('-');
                last_was_dash = true;
            }
        }
    }
    while out.ends_with('-') {
        out.pop();
    }
    if out.is_empty() {
        "paragraph".to_string()
    } else {
        out
    }
}

/// 1.2.8+ — truncate a captured shell-output blob to at most
/// `max_lines` lines.  Returns the input unchanged if it
/// already fits.  When truncated, the last kept line is
/// replaced with `… (N more lines truncated)` so the user
/// sees that something was dropped and how much.  The marker
/// itself counts toward `max_lines`, so the visible line
/// count never exceeds the configured cap.
///
/// Used at the App level (post-eval) rather than inside
/// `shell::Engine::eval` so the engine stays config-free —
/// it always returns whatever nu produced, and the UI layer
/// decides what to retain.
fn truncate_to_lines(text: String, max_lines: usize) -> String {
    if text.is_empty() {
        return text;
    }
    let cap = max_lines.max(1);
    let total: usize = text.lines().count();
    if total <= cap {
        return text;
    }
    // Keep cap-1 head lines, replace the rest with the marker.
    let keep = cap.saturating_sub(1);
    let head: String = text
        .lines()
        .take(keep)
        .collect::<Vec<_>>()
        .join("\n");
    let dropped = total - keep;
    let marker = format!(
        "… ({dropped} more lines truncated · raise shell.max_output_lines to keep more)"
    );
    if head.is_empty() { marker } else { format!("{head}\n{marker}") }
}

/// 1.2.21+ FF.4d — navigable findings from the last `Ctrl+B Shift+X`
/// fact-check, cycled by `Ctrl+B Shift+J`: each jumps the editor cursor
/// to the flagged claim in the open paragraph.
#[derive(Default)]
pub(crate) struct FactCheckNav {
    /// Paragraph the findings are for — navigation only works while it
    /// is the open paragraph.
    pub target: Option<Uuid>,
    pub findings: Vec<crate::facts_scan::FactFinding>,
    /// Cursor into `findings`; `usize::MAX` = "before the first".
    pub idx: usize,
}

pub(crate) struct App {
    layout: ProjectLayout,
    store: Store,
    keymap: Keymap,
    cfg: Config,
    ai: AiClient,
    prompts: PromptLibrary,

    /// 1.2.7+ — mouse capture toggle. True (the default)
    /// means crossterm captures every mouse event for the
    /// TUI (focus on click, scroll wheel, etc.). False
    /// releases capture so terminal-native drag-to-select +
    /// Cmd+C (macOS) / Ctrl+Shift+C (Linux/Windows) work
    /// inside the editor and AI pane. Toggled by
    /// `Ctrl+Shift+M`.
    mouse_captured: bool,

    /// 1.2.7+ — per-book swim-lane view state cache.
    /// Captured when the timeline view closes (Esc), restored
    /// on next `open_timeline_view` for the same book.
    /// Survives restart via `.session.json`.
    timeline_views: std::collections::HashMap<Uuid, TimelineViewSnapshot>,

    /// 1.2.7+ — single-slot kill-ring for the most recent
    /// PARAGRAPH delete. Captured in `commit_delete` just
    /// before `delete_subtree` fires (single-paragraph only;
    /// subtree deletes are too risky to undo without store
    /// API support). Restored by Ctrl+B U (pops the front =
    /// most-recent) or Ctrl+V Shift+U (picker over the
    /// whole ring) via `create_node` at the original
    /// position — content, tags, linked_paragraphs, event
    /// data preserved; the uuid changes (so paragraph links from
    /// elsewhere pointing at the deleted id stay broken).
    ///
    /// 1.2.8+ — was `Option<DeletedParagraphStash>` (single
    /// slot) in 1.2.7; widened to a VecDeque so accidental
    /// burst-deletes don't clobber an earlier recovery.
    /// Capped at `KILL_RING_CAP`; oldest entries drop off
    /// the back as the front fills.
    kill_ring: std::collections::VecDeque<DeletedParagraphStash>,

    /// 1.2.7+ — visited-paragraph history (browser-style
    /// back/forward). Pushed on every `load_paragraph` that
    /// isn't itself a back/forward navigation; truncated
    /// forward on a new push (just like a browser).
    /// Persisted in `.session.json`.
    visited_history: Vec<Uuid>,
    /// Index into `visited_history` pointing at the current
    /// paragraph. Back = -1, Forward = +1.
    visited_cursor: usize,
    /// Set by Alt+Left / Alt+Right before they call
    /// `open_paragraph_by_uuid` so the resulting
    /// `load_paragraph` doesn't re-push (which would
    /// instantly clobber the forward stack). Cleared by
    /// `load_paragraph` after read.
    visited_skip_next_push: bool,

    hierarchy: Hierarchy,
    rows: Vec<(Uuid, usize)>,
    /// Branches whose children are hidden in the tree pane. The branch
    /// itself stays visible; only its subtree is collapsed. Left arrow adds
    /// to this set, Right removes from it.
    collapsed_nodes: std::collections::HashSet<Uuid>,
    /// True after the user pressed the meta-prefix chord (default Ctrl+B).
    /// The next key is interpreted as an action selector and clears this.
    meta_pending: bool,
    /// True for one keystroke after the user presses Ctrl+V. The
    /// next key picks an export variant — `1` / `2` whose meaning
    /// depends on the current focus (editor: buffer / subchapter;
    /// tree: current node + descendants). All variants produce
    /// markdown and write to the launch cwd with a deterministic
    /// stem so the user can find the file without a save dialog.
    view_pending: bool,
    /// True after the user pressed the Bund-meta prefix (default
    /// Ctrl+Z). The next key dispatches into `handle_bund_action`
    /// (R run, E eval, N new script).
    bund_pending: bool,
    modal: Modal,

    focus: Focus,
    tree_cursor: usize,
    tree_scroll: usize,

    search_input: TextInput,
    ai_input: TextInput,
    /// Sent AI prompts in chronological order (oldest first).
    /// Up/Down in the AI prompt walks this list when no prompt
    /// picker is showing. Cleared on every send via push_back.
    /// 1.2.4+.
    ai_prompt_history: Vec<String>,
    /// 1.2.8+ — F1 help-query history. Up/Down inside the
    /// `Modal::HelpQuery` input walks this ring. Pushed on
    /// every successful Enter (dedup against the immediate
    /// predecessor); not persisted (session-only) to keep
    /// stale "what did inkhaven 1.2.5 do for X" queries out
    /// of long-running ring tails.
    help_query_history: Vec<String>,
    /// Cursor into `help_query_history`; same semantics as
    /// `ai_prompt_history_cursor` — None = not navigating.
    help_query_history_cursor: Option<usize>,
    /// 1.2.8+ — F6 snapshot-picker annotation filter.
    /// Substring-match (case-insensitive) against the
    /// snapshot's annotation text; empty = show all.
    /// `/` toggles `snapshot_filter_focused`; while focused,
    /// typed chars edit the filter (D/V/Enter chord keys
    /// route into the filter instead of firing actions).
    /// Reset to defaults each `open_snapshot_picker`.
    snapshot_filter: String,
    snapshot_filter_focused: bool,
    /// 1.2.8+ — embedded nushell.  `None` until first
    /// `Ctrl+Z o` opens the pane (lazy init); persists
    /// across close + reopen so env-var mutations (`$env.X
    /// = ...`) and the engine state stay alive between
    /// sessions of the pane.  `Ctrl+Z O` (Shift) destroys
    /// this and rebuilds fresh.
    shell_engine: Option<super::shell::Engine>,
    /// 1.2.8+ — turn buffer for the shell pane.  Capped at
    /// `cfg.shell.max_buffered_turns`; oldest entries roll
    /// off the front as the back fills.  Lives on `App`
    /// rather than on `Modal::ShellPane` so it survives
    /// modal close/reopen — selection mode (Phase 6) and
    /// the typst-box insert read from this Vec.
    shell_history: Vec<super::shell::ShellTurn>,
    /// 1.2.8+ — command-only history ring for Up/Down
    /// arrow recall inside the shell input.  Pushed on each
    /// non-empty Enter; deduped against the immediate
    /// predecessor.  Persisted to
    /// `.inkhaven/shell_history.db` (1.2.8+ — see
    /// `shell_history_db`).
    shell_command_history: Vec<String>,
    /// 1.2.8+ — SQLite-backed history at
    /// `<project>/.inkhaven/shell_history.db`.  Loaded
    /// lazily on first `open_shell_pane`; appended on
    /// every non-empty Enter.  `None` until the first
    /// open.
    shell_history_db: Option<super::shell::History>,
    /// 1.2.8+ — pane-local Ctrl+B chord prefix.  Kept
    /// separate from the global `bund_pending` so the
    /// shell pane's `Ctrl+B H` doesn't clash with the
    /// global Ctrl+B chord ladder (Quit, Load, …).
    /// `true` after Ctrl+B inside the pane, consumed by
    /// the next keystroke.
    shell_ctrlb_pending: bool,
    /// TTS engine handle.  1.2.9 had a single `Say`
    /// (macOS-only) field; 1.2.17 T.1 replaces it with
    /// the `TtsEngine` enum dispatcher so the same speak
    /// paths can route to either the legacy System
    /// backend (`/usr/bin/say` subprocess) or the new
    /// Piper neural backend (lands across T.2–T.5).
    /// Resolved in `App::new` from `editor.tts.engine`.
    tts: super::tts::TtsEngine,
    /// 1.2.9+ — session-local override for
    /// `editor.style_warnings.enabled`.  `Ctrl+V w`
    /// flips this; `true` forces overlays on regardless
    /// of HJSON, `false` forces off, `None` (the
    /// default) defers to the HJSON setting.
    style_warnings_toggle: Option<bool>,
    /// 1.2.20+ C.1.b — session-local override for
    /// `editor.echo_overlay` (`Ctrl+B Shift+K`); same
    /// three-state semantics as `style_warnings_toggle`.
    echo_overlay_toggle: Option<bool>,
    /// 1.2.20+ C.1.b — cached set of stems echoing near
    /// the open paragraph, recomputed by
    /// `refresh_echo_overlay`.  The render path turns this
    /// into per-line underlines.
    echo_overlay_stems: std::collections::HashSet<String>,
    /// 1.2.20+ C.1.b — chapter-paragraph cache for the
    /// echo overlay, keyed by the open paragraph id:
    /// `(open_id, [(para_id, plain_text), …])`.  Re-gathered
    /// (one store read per chapter paragraph) only when the
    /// open paragraph changes, not per keystroke.
    echo_overlay_chapter: Option<(Uuid, Vec<(Uuid, String)>)>,
    /// 1.2.20+ C.1.b — `(open_id, content_hash)` of the
    /// last `refresh_echo_overlay` compute, so idle ticks
    /// and unrelated keystrokes skip the echo pass.
    echo_overlay_last_key: Option<(Uuid, u64)>,
    /// 1.2.9+ — session-local override for
    /// `editor.pov_chip_enabled`.  `Ctrl+B Shift+P`
    /// flips this; semantics identical to
    /// `style_warnings_toggle`.
    pov_chip_toggle: Option<bool>,
    /// 1.2.12+ Phase C — session-local override for
    /// `editor.prompt_language_mode`.  `Ctrl+B Shift+N`
    /// cycles this through `Some("book_defined")` /
    /// `Some("paragraph_detected")` / `None` (defer to
    /// HJSON).  Same shape as `pov_chip_toggle` /
    /// `style_warnings_toggle`.  The AI pane title bar
    /// reflects the effective mode so the user can
    /// confirm what the resolver will target.
    prompt_lang_mode_runtime: Option<String>,
    /// Cursor into `ai_prompt_history`. None when not navigating;
    /// `Some(i)` when the user is stepping through history. Any
    /// edit (typing, backspace, etc.) clears it so the next Up
    /// arrow starts at the end of the list again.
    ai_prompt_history_cursor: Option<usize>,

    opened: Option<OpenedDoc>,
    /// "Similar paragraphs" mode: when `Some`, a second
    /// paragraph is loaded side-by-side with `opened`. The right
    /// editor pane (which normally holds the AI pane) is
    /// repurposed to render this doc. Set by the SimilarPicker
    /// modal; cleared by re-pressing Ctrl+V S, which first saves
    /// both buffers.
    ///
    /// `self.opened` always carries the **focused** doc — Tab in
    /// similar mode swaps `opened` ↔ `secondary` so the existing
    /// editor key handlers keep working unchanged. The
    /// `secondary_in_left_pane` flag tells the renderer which
    /// physical pane currently holds `opened`.
    secondary: Option<OpenedDoc>,
    /// 1.2.12+ — fullscreen two-paragraph split-view
    /// layout flag.  When `true` and `secondary.is_some()`,
    /// the renderer draws both `opened` and `secondary` as
    /// equal-width fullscreen editors with the tree pane
    /// and AI response pane hidden.  Toggled by `Shift+F4`
    /// (Action::ToggleSplitView).  Distinct from F4 split-
    /// edit (which is same-paragraph, snapshot-bottom) and
    /// from the `secondary_focused` flag (which says
    /// *which* of the two slots has focus, not whether the
    /// fullscreen layout is on).  See
    /// `Documentation/PROPOSALS/SPLIT_VIEW.md`.
    split_view: bool,
    /// 1.2.12+ — explicit "similar-paragraph mode is
    /// active" flag, used by the standard-layout
    /// renderer to decide whether to draw the
    /// `secondary` doc in the right column instead of
    /// the AI pane.  Pre-1.2.12 the renderer used
    /// `secondary.is_some()` as the trigger, which
    /// meant Shift+F4 split-view and `Ctrl+V S`
    /// similar-mode shared a single signal — pinning
    /// for one displaced the other.  Now they're
    /// orthogonal: `similar_mode = true` enters
    /// similar-mode display; `split_view = true`
    /// enters split-view display; both can be on
    /// simultaneously without bleeding into each
    /// other (split-view always wins for layout
    /// dispatch).
    similar_mode: bool,
    /// 1.2.12+ — when a picker is open, which slot does it
    /// target?  Captured from focus when the picker opens
    /// (NOT when it accepts) so the user can't accidentally
    /// route the wrong way by Tabbing while the picker is
    /// up.  Phase B wires every paragraph-opening picker
    /// through this; Phase A initialises the field and
    /// adds the helper functions but no caller consumes it
    /// yet.
    #[allow(dead_code)]
    picker_target: PaneTarget,
    /// Cached writing-progress snapshot, refreshed on every save +
    /// on project open. Status-bar widget reads from this cache so
    /// per-frame redraws don't trigger a hierarchy walk. `None`
    /// means "progress disabled / not yet computed".
    progress_cache: Option<crate::progress::ProgressSnapshot>,
    /// "Select paragraph to link" mode (1.2.4+). When Some, the
    /// tree pane shows a custom title and `Enter` on a paragraph
    /// links it to the owning UUID stashed here. Esc / tree-focus
    /// loss exits the mode and restores normal Enter semantics.
    ///
    /// Direction:
    /// * `Outgoing` — the Ctrl+V A flow. Open paragraph
    ///   `linked_paragraphs` gains the tree-picked target.
    /// * `Incoming` — the Ctrl+V I flow. The tree-picked
    ///   paragraph's `linked_paragraphs` gains the open one
    ///   (i.e. creates a link FROM the picked paragraph TO
    ///   current).
    link_pick_for: Option<(Uuid, LinkPickDirection)>,
    /// Multi-select set in the tree pane (1.2.4+). Toggled by
    /// `Space` on a row; when non-empty, `Ctrl+B R` (cycle
    /// status) and `Ctrl+V T` (set target) operate on every
    /// marked paragraph instead of just the cursor's. Cleared
    /// by `Esc` in the tree.
    tree_marked: std::collections::HashSet<Uuid>,
    /// In similar-paragraph mode, which pane has keyboard focus.
    /// `false` (default) → left pane = `self.opened` is the
    /// keyboard target. `true` → right pane = `self.secondary`
    /// is the target. Tab inside `Focus::Editor` flips this flag.
    /// All existing editor handlers continue to read/write
    /// `self.opened`; routing happens via a swap performed at
    /// the key-dispatch + save boundaries.
    secondary_focused: bool,
    status: String,
    show_results_overlay: bool,
    results: Vec<SearchHit>,
    results_cursor: usize,

    /// System clipboard handle. May be None on headless systems or when init
    /// fails; in that case copy/cut/paste use tui-textarea's internal yank
    /// buffer only.
    clipboard: Option<arboard::Clipboard>,

    highlighter: TypstHighlighter,
    /// Decoded ratatui colours for the active theme; built once at startup
    /// from `cfg.theme`. Read everywhere the renderer used to hard-code
    /// `Color::Cyan` / `Color::DarkGray` / etc.
    theme: super::theme::Theme,

    /// Place/Character names recompiled into regexes for the editor highlight
    /// overlay. Rebuilt after every save and at startup. None means an empty
    /// lexicon — render path skips work.
    lexicon: super::lexicon::Lexicon,

    /// 1.2.13+ Phase B.2 — side-product of `build_lexicon`.
    /// Maps lowercased surface forms (lemma + every
    /// paradigm form parsed out of dictionary entry HJSON
    /// bodies) to the `DictionaryEntry` it came from, so
    /// the editor footer can render a
    /// `[word · POS · translation]` chip when the cursor
    /// lands on a Language hit.  Rebuilt alongside
    /// `lexicon`.
    language_entries: super::lexicon_build::LanguageEntryIndex,

    inference: Option<Inference>,
    /// Destination for the AI-pane `L` (lift) chord — set by the
    /// submission / structural-analysis generators so their finished output
    /// can be filed into its system book. Stamped with the owning
    /// inference's `started_at`; ignored once a different inference is shown.
    lift_target: Option<LiftTarget>,
    show_prompt_picker: bool,
    prompt_picker_cursor: usize,

    /// Saved (cursor_row, cursor_col, scroll_row, scroll_col) per paragraph
    /// UUID. Updated when the editor loses focus, when the user switches
    /// paragraphs, and at exit. Loaded from `.session.json` on startup so
    /// positions survive across runs.
    paragraph_cursors: std::collections::HashMap<Uuid, ParagraphCursor>,

    /// Cumulative AI chat history for the current session. Each prompt the
    /// user sends from the AI prompt bar appends a User turn, and the
    /// resulting assistant response (when streaming finishes) appends an
    /// Assistant turn. The full history is replayed back to the model on
    /// every follow-up so the conversation is continuous. Cleared by F9 or
    /// the meta-prefix Ctrl+B C.
    ///
    /// Help (F1 / `Help!`) inferences are deliberately *not* added here —
    /// they're one-shot RAG flows with a separate strict system prompt and
    /// don't benefit from carrying chat context.
    chat_history: Vec<ChatTurn>,
    /// User-supplied system-prompt override set via the
    /// `ink.ai.set_system_prompt` Bund stdlib word. When `Some`,
    /// every AI inference (start_inference / start_help_inference
    /// / grammar check) uses this string instead of the
    /// inference-mode-derived default. Cleared by passing empty
    /// string. Volatile — not persisted to HJSON.
    system_prompt_override: Option<String>,
    /// Captures the user message of the currently-streaming chat inference
    /// so we can record the matching Assistant turn into `chat_history`
    /// once the stream finishes. None during one-shot (Help) inferences.
    pending_chat_user_msg: Option<String>,

    /// 1.2.6+ — paragraph UUID to stamp the next completed
    /// inference's turns onto via `Node.ai_memory`. Set at
    /// send-time when (a) `ai.per_paragraph_memory` is on,
    /// (b) mode_used == Paragraph, and (c) a paragraph is
    /// open. Consumed by `pump_inference` on stream
    /// completion alongside `pending_chat_user_msg`.
    pending_paragraph_memory_target: Option<Uuid>,
    /// 1.2.11+ — set by `start_sentence_rhythm_rewrite`
    /// (Ctrl+B Shift+M) when an inference is in flight
    /// that should auto-open the AI diff modal on
    /// completion.  `pump_inference` watches for the
    /// Streaming → Done transition and triggers the
    /// modal with `post_accept_snapshot = Some("Sentence
    /// rhythm rewrite")` so the apply step preserves
    /// the pre-rewrite state under an F6-discoverable
    /// snapshot.  Cleared after either auto-open OR
    /// an error path. `Some(annotation)` while a rewrite (sentence-rhythm,
    /// or a 1.3.7 editorial fix) streams — the annotation is the snapshot
    /// label the diff-apply uses; `None` when no rewrite is pending.
    pub(super) pending_rewrite_diff: Option<String>,
    /// 1.3.9+ — when a span-scoped editorial fix (`f` on a localized finding)
    /// is streaming, the paragraph-relative char range `[start, end)` whose
    /// text the model's reply replaces. `Some` → splice the reply into that
    /// range and diff the whole paragraph; `None` → whole-paragraph replace
    /// (the pre-1.3.9 behaviour). Paired with `pending_rewrite_diff`; cleared
    /// on the same auto-open / error paths.
    pub(super) pending_rewrite_span: Option<(usize, usize)>,
    /// 1.3.9+ — an active "fix all of category X" batch from the Editorial
    /// Pass cockpit (`F`). `Some` while walking the queue; each accept/reject
    /// of the diff review advances it. `None` = no batch (single `f` and the
    /// rhythm rewrite leave it untouched, so they never auto-advance).
    pub(super) editorial_batch: Option<EditorialBatch>,
    /// 1.3.12 DEEP-1 — an in-flight background job (e.g. the deep AI refresh)
    /// run off the main thread against a shared `Store` clone. `None` when
    /// idle; the main loop drains its channel each tick. One at a time.
    pub(super) bg_job: Option<BgJob>,
    /// 1.2.21+ FF.4d — a fact-check chord awaiting its stream's
    /// completion: `(target paragraph id, title)`.  Consumed in
    /// `pump_inference` to parse the verdict into `fact_check_nav`.
    pub(super) fact_check_pending: Option<(Uuid, String)>,
    /// 1.2.21+ FF.4d — the parsed, navigable findings.
    pub(super) fact_check_nav: FactCheckNav,

    /// 1.2.13+ Phase C.2 — set true when a translation
    /// inference is in flight.  Drives translation-aware
    /// `Insert` extraction: when the AI pane fires its
    /// `I` apply chord on a finished translation
    /// inference, the apply path lifts ONLY the
    /// `<<<TRANSLATION>>> … <<<END>>>` block (the target-
    /// language prose, no commentary, no gloss table)
    /// rather than the full markdown→Typst converted
    /// reply.  Cleared at extraction time so a second
    /// `I` press lands the full body (escape hatch when
    /// the LLM forgot the markers).
    pub(super) pending_translation: bool,

    /// 1.2.14+ Phase Q.3a — set true while a
    /// `Ctrl+V d` continuation inference is in
    /// flight.  Drives the AI pane's `I` apply to
    /// lift only the `<<<DRAFT>>>` block.  Cleared
    /// at extraction.
    pub(super) pending_continuation_draft: bool,

    /// 1.2.14+ Phase Q.4b — set true while a
    /// `Ctrl+V y` style-transfer inference is in
    /// flight.  Drives the AI pane's `I` apply to
    /// lift only the `<<<REWRITE>>>` block.
    pub(super) pending_style_transfer: bool,

    /// RAG context block (e.g. a place/character lookup) that the next
    /// AI-prompt submission should prepend to the user's typed query.
    /// Used by the Ctrl+B P / Ctrl+B C editor flows when the AI prompt is
    /// empty: the context is stashed, focus jumps to the AI prompt so the
    /// user can type their question, and `start_inference` lifts the
    /// prefix on Enter.
    pending_rag_prefix: Option<String>,

    /// Per-pane rectangles cached from the most recent `draw()` so the
    /// mouse handler can map a click coordinate to the right pane. Empty
    /// `Rect`s before the first frame is drawn — every handler checks
    /// `contains` so a click during that window safely no-ops.
    layout_search: Rect,
    layout_tree: Rect,
    layout_editor: Rect,
    layout_ai: Rect,
    layout_ai_prompt: Rect,

    /// Active AI "scope" picker. Cycled with F9. When set to anything but
    /// `None`, the next AI-prompt submission prepends the matching context
    /// (selection text, paragraph body, subchapter / chapter / book
    /// concatenation) to the user's query, then auto-resets to `None`.
    ai_mode: AiMode,

    /// How aggressively the model may draw on its own knowledge. Toggled
    /// globally by F10. Help inferences pin this to `Local` regardless of
    /// the current value (see `start_help_inference`).
    inference_mode: InferenceMode,

    /// Set by `commit_file_pick` when the user picks a directory to
    /// import. The main loop picks this up, renders a progress splash,
    /// and runs the (synchronous) import with periodic redraws. None
    /// during normal operation.
    pending_import: Option<std::path::PathBuf>,

    /// Set by Ctrl+B A (`schedule_assembly`). Main loop drives the
    /// synchronous book-assembly procedure with a progress splash.
    pending_assembly: Option<Uuid>,

    /// Set by Ctrl+B B — run assembly + `typst compile`, surface
    /// errors via a fresh AI chat tuned for typst diagnostics.
    pending_build: Option<Uuid>,
    /// 1.2.6+ — set by `Ctrl+B Shift+B` (Action::BackupNow). The
    /// main event loop runs `run_manual_backup` next tick so the
    /// splash + wait-for-key dance happens off the chord-handler
    /// path (which doesn't own the terminal).
    pending_backup_now: bool,

    /// Set by Ctrl+B O — Ctrl+B B + copy the resulting PDF into the
    /// inkhaven launch cwd with a timestamped filename.
    pending_take: Option<Uuid>,

    /// Typewriter-style SFX (Enter key + editor-pane focus-out). None
    /// when the host has no audio device — every play call then
    /// silently no-ops, so a headless / SSH-without-audio session
    /// behaves identically to a desktop with `sound.enabled = false`.
    sound: Option<super::sound::SoundPlayer>,

    /// Cached ratatui-image Picker — queried once at startup from the
    /// host terminal. None when the query failed (CI, weird ssh
    /// session, terminals without ANSI graphics support); preview
    /// then falls back to the status-bar info line. Also None when
    /// `images.preview_enabled = false` regardless of capability.
    image_picker: Option<ratatui_image::picker::Picker>,

    /// Ctrl+B W toggles full-screen "typewriter mode" — the editor
    /// pane expands to the whole terminal, every other pane (search
    /// bar, tree, AI, AI prompt, status bar) is hidden. The same
    /// chord returns to the normal layout.
    typewriter_mode: bool,

    /// Ctrl+B K toggles full-screen AI mode — left half = the live
    /// AI pane (streaming inference), right half = scrolling chat
    /// history, bottom = AI prompt. Same chord returns to the
    /// normal layout. Mutually exclusive with `typewriter_mode`.
    ai_fullscreen: bool,

    /// Extra lines to scroll the chat-history pane UP from its auto-
    /// bottom-pin. PageUp adds, PageDown subtracts; the value is
    /// clamped against the total line count so over-scrolling stops
    /// at the top of the history. Reset to 0 each time a new user
    /// message is sent so the streaming reply is visible.
    chat_history_scroll: usize,

    /// Active chat-history search state (Ctrl+F in AI-fullscreen).
    /// While Some, matching lines render with a highlight bg and
    /// the renderer scrolls so the `current` match lands in the
    /// middle of the pane. Ctrl+X advances toward older matches
    /// (the spec: "Start from bottom, going up to older").
    chat_search: Option<ChatSearchState>,

    /// Active chat-selection mode (Ctrl+C in AI-fullscreen). `Some`
    /// when the user is selecting a turn block. Up / Down navigate;
    /// `C` copies the selected turn to the system clipboard, `T`
    /// inserts it into the editor buffer at the cursor.
    chat_selection: Option<ChatSelectionState>,

    /// 1.2.15+ Phase R.1 — wall-clock of the last
    /// dirty-buffer mirror push to the crash-report
    /// context.  Debounces the per-tick mirror call
    /// to at most once per [`CRASH_MIRROR_DEBOUNCE_SECS`]
    /// seconds — mirroring every 200 ms tick would
    /// burn CPU on long buffers without changing the
    /// outcome (the user can't lose more work than
    /// one debounce window's worth).  `None` until
    /// the first mirror.
    last_crash_mirror_at: Option<std::time::Instant>,

    /// 1.2.15+ Phase H.1 — receiver for events
    /// from the background health monitor task.
    /// `None` when `health.enabled = false` in
    /// HJSON (no monitor was spawned) — the chip
    /// then stays hidden.  Pumped on every tick;
    /// the most recent event drives `health_chip`.
    health_rx: Option<tokio::sync::mpsc::UnboundedReceiver<crate::health::HealthEvent>>,
    /// 1.2.15+ Phase H.1 — derived from the latest
    /// event the TUI consumed.  Drives the status-
    /// bar chip's glyph + colour.  `Hidden` until
    /// the first event arrives.
    health_chip: crate::health::ChipState,
    /// 1.2.15+ Phase H.1 — most recent finding's
    /// detail string, used for status-bar tooltip
    /// expansion + future H.3 doctor-panel
    /// integration.  `None` while chip is `Clean`
    /// or `Hidden`.
    health_detail: Option<String>,
}

/// 1.2.15+ Phase R.1 — debounce window for the
/// dirty-buffer mirror push to the crash-report
/// context.  Two seconds is enough that idle CPU is
/// negligible while still bounding worst-case
/// post-panic data loss to ~2 s of typing.
const CRASH_MIRROR_DEBOUNCE_SECS: u64 = 2;

mod ai_impl;
mod backup_impl;
mod comments_impl;
mod editor_impl;
mod q3_q4_impl;
mod render;
mod snapshot_impl;
mod tag_impl;
mod threads_impl;
mod timeline_impl;
mod tree_impl;

impl App {
    fn new(layout: ProjectLayout, cfg: Config, store: Store) -> Result<Self> {
        let keymap = Keymap::from_config(&cfg).map_err(anyhow::Error::from)?;
        // Build the chord-action table from the user's HJSON
        // overlay. Defaults first, then `keys.bindings` rewrites.
        // Install into the process-wide slot so `ink.key.*` Bund
        // stdlib words can mutate the same source of truth the
        // App reads from on every chord dispatch.
        let overrides: Vec<(String, String, Option<String>)> = cfg
            .keys
            .bindings
            .iter()
            .map(|b| (b.chord.clone(), b.action.clone(), b.scope.clone()))
            .collect();
        let keys = super::keybind::KeyBindings::from_overrides(
            keymap.meta_prefix,
            keymap.bund_prefix,
            keymap.view_prefix,
            &overrides,
        )
        .map_err(|e| Error::Config(format!("keys.bindings: {e}")))?;
        super::keybind::install(keys);
        let hierarchy = Hierarchy::load(&store).map_err(anyhow::Error::from)?;
        let (lexicon, language_entries) = build_lexicon(&hierarchy, &cfg, &store);
        let collapsed_nodes: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
        let rows: Vec<(Uuid, usize)> = hierarchy
            .flatten_with_collapsed(&collapsed_nodes)
            .into_iter()
            .map(|(n, d)| (n.id, d))
            .collect();

        // Background sync: every `sync_interval_seconds` flush the HNSW
        // index (cheap no-op when clean) and force a DuckDB CHECKPOINT
        // against `metadata.db` + `blobs.db` (cheap when WAL is empty).
        // Both ops short-circuit when there's no real work, so the tick
        // can be generous (default 600s). Store is Send+Sync and cheap to
        // clone (Arc inside). 0 disables.
        if cfg.sync_interval_seconds > 0 {
            let store_for_sync = store.clone();
            let interval_secs = cfg.sync_interval_seconds;
            tokio::spawn(async move {
                let period = std::time::Duration::from_secs(interval_secs);
                let mut ticker = tokio::time::interval(period);
                // Skip the immediate first tick (interval fires at t=0).
                ticker.tick().await;
                loop {
                    ticker.tick().await;
                    if let Err(e) = store_for_sync.sync() {
                        tracing::warn!("background sync failed: {e}");
                    }
                    if let Err(e) = store_for_sync.checkpoint() {
                        tracing::warn!("background checkpoint failed: {e}");
                    }
                }
            });
        }

        let ai = AiClient::from_config(&cfg.llm).map_err(anyhow::Error::from)?;

        let prompts_path = layout.prompts_path(&cfg);
        let prompts = if prompts_path.is_file() {
            PromptLibrary::load(&prompts_path).map_err(anyhow::Error::from)?
        } else {
            PromptLibrary::default()
        };

        let theme = super::theme::Theme::from_config(&cfg.theme);
        // Try to claim the default audio device. None on hosts without
        // one — the player then silently no-ops, mirroring
        // `sound.enabled = false`.
        let sound =
            super::sound::SoundPlayer::try_new(cfg.sound.enabled, cfg.sound.volume);
        // Probe the host terminal for graphics-protocol support so the
        // image-preview modal can pick kitty / sixel / iterm2 / half-
        // block. Errors here just disable the preview pane; the rest
        // of the app behaves identically.
        let image_picker = if cfg.images.preview_enabled {
            match ratatui_image::picker::Picker::from_query_stdio() {
                Ok(p) => Some(p),
                Err(e) => {
                    tracing::info!("image preview disabled — terminal probe: {e}");
                    None
                }
            }
        } else {
            None
        };
        let initial_mouse_captured = cfg.editor.mouse_captured;
        // Resolve the TTS engine before the struct
        // literal moves `cfg` + `layout` into the App.
        // T.1: "auto" falls through to System on every
        // host because the Piper stub always errors.
        let tts =
            super::tts::TtsEngine::resolve(&cfg.editor.tts, &layout.root);
        Ok(Self {
            layout,
            store,
            keymap,
            cfg,
            ai,
            prompts,
            mouse_captured: initial_mouse_captured,
            timeline_views: std::collections::HashMap::new(),
            kill_ring: std::collections::VecDeque::new(),
            visited_history: Vec::new(),
            visited_cursor: 0,
            visited_skip_next_push: false,
            hierarchy,
            rows,
            modal: Modal::None,
            collapsed_nodes,
            meta_pending: false,
            bund_pending: false,
            view_pending: false,
            focus: Focus::Tree,
            tree_cursor: 0,
            tree_scroll: 0,
            search_input: TextInput::new(),
            ai_input: TextInput::new(),
            ai_prompt_history: Vec::new(),
            ai_prompt_history_cursor: None,
            help_query_history: Vec::new(),
            help_query_history_cursor: None,
            snapshot_filter: String::new(),
            snapshot_filter_focused: false,
            shell_engine: None,
            shell_history: Vec::new(),
            shell_command_history: Vec::new(),
            shell_history_db: None,
            shell_ctrlb_pending: false,
            tts,
            style_warnings_toggle: None,
            echo_overlay_toggle: None,
            echo_overlay_stems: std::collections::HashSet::new(),
            echo_overlay_chapter: None,
            echo_overlay_last_key: None,
            pov_chip_toggle: None,
            prompt_lang_mode_runtime: None,
            opened: None,
            secondary: None,
            secondary_focused: false,
            split_view: false,
            similar_mode: false,
            picker_target: PaneTarget::Primary,
            progress_cache: None,
            link_pick_for: None,
            tree_marked: std::collections::HashSet::new(),
            status: String::from(
                "Tab=panes · Enter=open · Ctrl+S=save · Ctrl+B then B/C/S/P add · D delete · ↑/↓ reorder · Ctrl+Q quit",
            ),
            show_results_overlay: false,
            results: Vec::new(),
            results_cursor: 0,
            clipboard: arboard::Clipboard::new().ok(),
            highlighter: TypstHighlighter::new()
                .map_err(|e| anyhow::anyhow!("typst highlighter init: {e}"))?,
            theme,
            lexicon,
            language_entries,
            inference: None,
            lift_target: None,
            show_prompt_picker: false,
            prompt_picker_cursor: 0,
            paragraph_cursors: std::collections::HashMap::new(),
            chat_history: Vec::new(),
            system_prompt_override: None,
            pending_chat_user_msg: None,
            pending_paragraph_memory_target: None,
            pending_rewrite_diff: None,
            pending_rewrite_span: None,
            editorial_batch: None,
            bg_job: None,
            fact_check_pending: None,
            fact_check_nav: FactCheckNav::default(),
            pending_translation: false,
            pending_continuation_draft: false,
            pending_style_transfer: false,
            pending_rag_prefix: None,
            layout_search: Rect::default(),
            layout_tree: Rect::default(),
            layout_editor: Rect::default(),
            layout_ai: Rect::default(),
            layout_ai_prompt: Rect::default(),
            ai_mode: AiMode::None,
            inference_mode: InferenceMode::Full,
            pending_import: None,
            pending_assembly: None,
            pending_build: None,
            pending_backup_now: false,
            pending_take: None,
            sound,
            image_picker,
            typewriter_mode: false,
            ai_fullscreen: false,
            chat_history_scroll: 0,
            chat_search: None,
            chat_selection: None,
            last_crash_mirror_at: None,
            health_rx: None,
            health_chip: crate::health::ChipState::default(),
            health_detail: None,
        })
    }

    /// Open the progress store under the project root and seed
    /// today's baselines. Called from `run` after `App::new` and
    /// before the event loop spins up. Failures are logged + the
    /// store stays uninstalled — progress tracking degrades to
    /// "(disabled)" rather than aborting startup.
    pub(crate) fn install_progress(&mut self) {
        if let Err(e) = crate::progress::install(&self.layout.root) {
            tracing::warn!(target: "inkhaven::progress", "install: {e:#}");
            return;
        }
        // Snapshot today's per-book + project-wide totals so
        // `today_words` has a stable reference point. Idempotent
        // per (day, book) — subsequent calls in the same UTC day
        // are silent no-ops.
        let (per_book, project_total) = self.compute_word_totals_now();
        crate::progress::capture_today_baselines(&per_book, project_total);
        self.refresh_progress_cache();
    }

    /// Rebuild `self.progress_cache` from the current hierarchy +
    /// progress store. Cheap enough to call per save (one hierarchy
    /// walk + a handful of small DuckDB selects). The status bar
    /// always reads from the cache so it doesn't pay the cost on
    /// every redraw.
    ///
    /// 1.2.4+: fires two Bund hooks when the snapshot transitions
    /// across a meaningful threshold — see `fire_progress_hooks`.
    pub(crate) fn refresh_progress_cache(&mut self) {
        let (per_book_vec, project_total) = self.compute_word_totals_now();
        let mut per_book: std::collections::HashMap<Uuid, i64> =
            std::collections::HashMap::new();
        let mut book_titles: std::collections::HashMap<Uuid, String> =
            std::collections::HashMap::new();
        let mut book_slugs: std::collections::HashMap<Uuid, String> =
            std::collections::HashMap::new();
        for (id, total) in per_book_vec {
            per_book.insert(id, total);
            if let Some(node) = self.hierarchy.get(id) {
                book_titles.insert(id, node.title.clone());
                book_slugs.insert(id, node.slug.clone());
            }
        }
        let live = crate::progress::LiveTotals {
            per_book,
            project_total,
            book_titles,
            book_slugs,
        };
        let prev = self.progress_cache.clone();
        let snap = crate::progress::snapshot(&self.cfg.goals, &live);
        self.fire_progress_hooks(prev.as_ref(), &snap);
        self.progress_cache = Some(snap);
    }

    /// Diff the previous progress snapshot against the new one
    /// and fire the matching transition hooks.
    ///
    /// * `hook.on_goal_hit ( today_words daily_goal -- )` — fired
    ///   the first time `today_words` crosses `daily_goal` on a
    ///   given day. Doesn't re-fire while still over the line;
    ///   self-resets if the user deletes back below it.
    /// * `hook.on_streak_break ( prev_streak_days -- )` — fired
    ///   when the streak transitions from positive to zero. The
    ///   argument is the streak length immediately before the
    ///   break so a hook can log/notify "you just broke a 12-day
    ///   streak".
    fn fire_progress_hooks(
        &self,
        prev: Option<&crate::progress::ProgressSnapshot>,
        new: &crate::progress::ProgressSnapshot,
    ) {
        // on_goal_hit
        if let Some(goal) = new.project.daily_goal.filter(|n| *n > 0) {
            let prev_today = prev
                .map(|p| p.project.today_words)
                .unwrap_or(0);
            let new_today = new.project.today_words;
            if prev_today < goal && new_today >= goal {
                crate::scripting::hooks::fire(
                    "hook.on_goal_hit",
                    vec![
                        rust_dynamic::value::Value::from_int(new_today),
                        rust_dynamic::value::Value::from_int(goal),
                    ],
                );
            }
        }
        // on_streak_break — only fires when we had a positive
        // streak previously and now we have zero. First-launch
        // (prev is None) doesn't count as a break.
        let prev_streak = prev.map(|p| p.streak.days).unwrap_or(0);
        if prev_streak > 0 && new.streak.days == 0 {
            crate::scripting::hooks::fire(
                "hook.on_streak_break",
                vec![rust_dynamic::value::Value::from_int(prev_streak)],
            );
        }
        // on_active_goal_hit — same transitional semantics as
        // on_goal_hit but against `goals.active_minutes_daily`.
        let active_goal_secs = self.cfg.goals.active_minutes_daily.max(0) * 60;
        if active_goal_secs > 0 {
            let prev_active = prev.map(|p| p.active_seconds_today).unwrap_or(0);
            let new_active = new.active_seconds_today;
            if prev_active < active_goal_secs && new_active >= active_goal_secs {
                crate::scripting::hooks::fire(
                    "hook.on_active_goal_hit",
                    vec![
                        rust_dynamic::value::Value::from_int(new_active),
                        rust_dynamic::value::Value::from_int(active_goal_secs),
                    ],
                );
            }
        }
    }

    /// Walk the hierarchy and compute current per-book + project
    /// word totals. Touches the filesystem (reads paragraph
    /// bodies) — okay to call once at startup; called from the
    /// progress modal too where the cost amortises across an
    /// interactive open.
    pub(crate) fn compute_word_totals_now(
        &self,
    ) -> (Vec<(Uuid, i64)>, i64) {
        use crate::progress::word_count::count_words;
        let mut per_book: std::collections::HashMap<Uuid, i64> =
            std::collections::HashMap::new();
        for (node, _) in self.hierarchy.flatten() {
            if node.kind != NodeKind::Paragraph {
                continue;
            }
            // Skip protected (system / Help) books — they're not
            // user manuscript content.
            let in_user_book = self
                .hierarchy
                .ancestors(node)
                .into_iter()
                .find(|a| a.kind == NodeKind::Book)
                .filter(|b| b.system_tag.is_none());
            let Some(book) = in_user_book else { continue };
            let Some(rel) = node.file.as_ref() else { continue };
            let abs = self.layout.root.join(rel);
            let body = std::fs::read_to_string(&abs).unwrap_or_default();
            let n = count_words(&body);
            *per_book.entry(book.id).or_insert(0) += n;
        }
        let project_total: i64 = per_book.values().sum();
        let per_book_vec: Vec<(Uuid, i64)> = per_book.into_iter().collect();
        (per_book_vec, project_total)
    }

    /// Final HNSW save + DuckDB CHECKPOINT before the App (and its
    /// `Store` handle, and therefore the duckdb connection pool) are
    /// dropped. Called from the exit sequence in `run(&Path)` so the
    /// `.db.wal` files are drained while we can still surface errors
    /// — the pool's own Drop impl would checkpoint implicitly, but
    /// silently.
    /// Evaluate a Bund script with `self` installed as the
    /// active `App`. Lets `ink.editor.* / ink.ai.* / ink.typst.*`
    /// stdlib words reach App state. Pure wrapper around
    /// `scripting::eval` — sets the global ACTIVE_APP slot via
    /// `AppGuard` before invoking, restores on RAII drop.
    pub(crate) fn scripting_eval(
        &mut self,
        code: &str,
    ) -> anyhow::Result<crate::scripting::EvalOutput> {
        let _guard = crate::scripting::AppGuard::enter(self);
        crate::scripting::eval(code)
    }

    fn shutdown_flush(&self) {
        if let Err(e) = self.store.sync() {
            tracing::warn!("shutdown sync failed: {e}");
        }
        if let Err(e) = self.store.checkpoint() {
            tracing::warn!("shutdown checkpoint failed: {e}");
        }
    }

    fn run<B: ratatui::backend::Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
        loop {
            self.pump_inference();
            self.pump_bg_job();
            self.tick_autosave();
            self.tick_crash_mirror();
            self.tick_health_pump();
            // 1.2.9+ — close the TTS playback modal as
            // soon as the engine reports it's idle, so
            // the modal disappears when the paragraph
            // finishes naturally (no user keystroke
            // needed).  Cheap call — single FFI
            // boolean.
            self.tts_poll_playback();
            // Drive any deferred directory import — `commit_file_pick`
            // sets `pending_import` so the splash can be drawn directly
            // via the terminal handle (which `commit_file_pick` doesn't
            // own). Runs synchronously: same UX as the backup splash.
            if let Some(root) = self.pending_import.take() {
                self.run_pending_import(terminal, &root);
            }
            // Same pattern for Book assembly (Ctrl+B A): the dispatcher
            // stashes the book uuid, the main loop runs the procedure
            // and drives the splash redraws.
            if let Some(book_id) = self.pending_assembly.take() {
                self.run_pending_assembly(terminal, book_id);
            }
            if let Some(book_id) = self.pending_build.take() {
                self.run_pending_build(terminal, book_id, false);
            }
            if let Some(book_id) = self.pending_take.take() {
                self.run_pending_build(terminal, book_id, true);
            }
            if std::mem::take(&mut self.pending_backup_now) {
                self.run_pending_backup_now(terminal);
            }
            // 1.2.20+ C.1.b — refresh the echo-overlay stem
            // cache before drawing (cheap when off or
            // unchanged).
            self.refresh_echo_overlay();
            terminal.draw(|f| self.draw(f))?;
            // Shorter poll interval while streaming so tokens render with low
            // latency without burning CPU when idle.  1.2.18+ R.4 — the
            // reader-pace teleprompter animates the highlight, so it also
            // wants the fast tick (but only while playing, not paused).
            let timeout = if self.is_streaming() || self.reader_pace_playing() {
                Duration::from_millis(40)
            } else {
                Duration::from_millis(200)
            };
            if event::poll(timeout)? {
                match event::read()? {
                    Event::Key(key) => {
                        if key.kind != KeyEventKind::Press {
                            continue;
                        }
                        if self.handle_key(key)? {
                            return Ok(());
                        }
                    }
                    Event::Mouse(mouse) => self.handle_mouse(mouse),
                    _ => {}
                }
            }
        }
    }

    fn tick_autosave(&mut self) {
        let secs = self.cfg.editor.autosave_seconds;
        if secs == 0 {
            return;
        }
        let due = match self.opened.as_ref() {
            // Suspend idle autosave while a grammar-correction overlay is
            // active so it doesn't disappear under the user's nose. The
            // overlay is dismissed by Ctrl+S (manual save), focus-out, or
            // Ctrl+B C — each of those resumes normal autosave.
            Some(doc) if doc.dirty && doc.correction_baseline.is_none() => {
                doc.last_activity.elapsed().as_secs() >= secs
            }
            _ => false,
        };
        if due {
            let _ = self.save_current();
        }

        // 1.2.4+: also autosave the secondary editor (similar-
        // paragraph mode's right pane). Before this, the secondary
        // doc was flush-only-on-Ctrl+V-S-exit, which surprised
        // users who typed in the right pane and walked away.
        // Same idle-threshold as the primary; no correction-overlay
        // gate since correction overlays only apply to the primary.
        // `Option::take()` pulls the doc out so we can call
        // `save_doc(&mut self, &mut OpenedDoc)` without aliasing
        // self.secondary against itself; the doc goes straight
        // back regardless of save outcome.
        let sec_due = match self.secondary.as_ref() {
            Some(doc) if doc.dirty => doc.last_activity.elapsed().as_secs() >= secs,
            _ => false,
        };
        if sec_due {
            if let Some(mut doc) = self.secondary.take() {
                if let Err(e) = self.save_doc(&mut doc) {
                    tracing::warn!(
                        target: "inkhaven::autosave",
                        "secondary autosave failed: {e}",
                    );
                }
                self.secondary = Some(doc);
            }
        }

        // 1.2.7+ — external-change watch. If the open
        // paragraph's file mtime moved since we loaded it,
        // someone else (CLI, sed, git pull) touched it.
        // Clean buffer → silently reload + status hint;
        // dirty buffer → red warning with a hint to use
        // Ctrl+B Shift+R to reload (losing local changes)
        // or Ctrl+S to overwrite. Cheap (one syscall per
        // tick) so safe at the autosave cadence.
        self.tick_external_change_check();

        // 1.2.5+: idle typst-syntax recheck. Independent of save
        // — runs whenever the user has paused for
        // `typst_compile.diagnostics_idle_seconds` and the buffer
        // has moved on since the last check. Save itself already
        // calls `refresh_typst_diagnostics_for_opened`, so this
        // covers the "I'm staring at the buffer wondering why
        // typst errored" case where no save has fired yet.
        if self.cfg.typst_compile.diagnostics {
            let idle = self.cfg.typst_compile.diagnostics_idle_seconds;
            let due = match self.opened.as_ref() {
                Some(doc) => {
                    let idle_ok =
                        doc.last_activity.elapsed().as_secs() >= idle;
                    let stale = doc
                        .typst_diagnostics_checked_at
                        .elapsed()
                        .as_secs()
                        >= idle.max(1);
                    idle_ok && stale && doc.dirty
                }
                None => false,
            };
            if due {
                self.refresh_typst_diagnostics_for_opened();
            }
        }
    }

    /// 1.2.15+ Phase H.1 — drain pending events
    /// from the health-monitor channel and update
    /// the status-bar chip + detail.  Cheap when
    /// idle (single `try_recv` returning Empty).
    /// We collapse multiple queued events down to
    /// the most recent — that's the state the chip
    /// should reflect.
    fn tick_health_pump(&mut self) {
        let Some(rx) = self.health_rx.as_mut() else {
            return;
        };
        loop {
            match rx.try_recv() {
                Ok(evt) => {
                    self.health_chip = evt.chip();
                    self.health_detail = match evt {
                        crate::health::HealthEvent::Ok => None,
                        crate::health::HealthEvent::Warning(f)
                        | crate::health::HealthEvent::Error(f) => Some(f.detail),
                        crate::health::HealthEvent::Repaired(_, note) => Some(note),
                    };
                }
                Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
                Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
                    // Monitor task exited — most
                    // likely the runtime is being
                    // torn down on shutdown.  Drop
                    // the receiver so we don't keep
                    // polling.
                    self.health_rx = None;
                    break;
                }
            }
        }
    }

    /// 1.2.15+ Phase R.1 — push the open paragraph's
    /// current buffer (only when dirty) into the
    /// crash-report context so the panic hook can
    /// flush it as a rescue file.  Debounced via
    /// `last_crash_mirror_at` to once every
    /// [`CRASH_MIRROR_DEBOUNCE_SECS`] seconds — that's
    /// the worst-case unsaved-typing window a panic
    /// can take down with it.  When the buffer is
    /// clean, the mirror is cleared by `save_current`
    /// directly (not here) so the periodic tick
    /// stays minimal: read field, check timer,
    /// maybe build a string and push.
    fn tick_crash_mirror(&mut self) {
        let Some(doc) = self.opened.as_ref() else {
            return;
        };
        if !doc.dirty {
            return;
        }
        let now = std::time::Instant::now();
        if let Some(last) = self.last_crash_mirror_at {
            if now.duration_since(last).as_secs() < CRASH_MIRROR_DEBOUNCE_SECS {
                return;
            }
        }
        let content = doc.textarea.lines().join("\n");
        let (cursor_row, cursor_col) = doc.textarea.cursor();
        crate::crash::context().mirror_buffer(
            doc.rel_path.clone(),
            crate::crash::DirtyMirror::new(content, cursor_row, cursor_col),
        );
        self.last_crash_mirror_at = Some(now);
    }

    /// 1.2.7+ — once per tick, check whether the open
    /// paragraph's file changed on disk since we loaded it.
    /// Three cases:
    ///   1. mtime unchanged → no-op.
    ///   2. mtime newer + buffer CLEAN → silent reload +
    ///      stamp the new mtime. Status notes the reload.
    ///   3. mtime newer + buffer DIRTY → red warning. We do
    ///      NOT clobber the user's edits; they decide via
    ///      Ctrl+S (overwrite the on-disk change) or by
    ///      copying their text elsewhere + manually
    ///      reloading.
    fn tick_external_change_check(&mut self) {
        let Some(doc) = self.opened.as_ref() else { return; };
        let Some(loaded_mtime) = doc.loaded_mtime else { return; };
        let abs = self.layout.root.join(&doc.rel_path);
        let on_disk_mtime = match std::fs::metadata(&abs).and_then(|m| m.modified()) {
            Ok(t) => t,
            Err(_) => return,
        };
        if on_disk_mtime <= loaded_mtime {
            return;
        }
        // File changed externally.
        if doc.dirty {
            // Status warning, don't touch the buffer.
            self.status = format!(
                "⚠ `{}` changed on disk while you have unsaved edits — Ctrl+S to overwrite the external change",
                doc.title
            );
            return;
        }
        // Clean buffer → silent reload.
        let body = match std::fs::read_to_string(&abs) {
            Ok(b) => b,
            Err(e) => {
                self.status =
                    format!("external reload failed: {}: {e}", abs.display());
                return;
            }
        };
        let lines = body_to_lines(&body);
        let title = doc.title.clone();
        let id = doc.id;
        if let Some(doc) = self.opened.as_mut() {
            let saved_lines = lines.clone();
            doc.textarea = TextArea::new(lines);
            doc.saved_lines = saved_lines;
            doc.dirty = false;
            doc.loaded_mtime = Some(on_disk_mtime);
            // Move cursor to (0, 0) — the previous
            // position may no longer make sense after an
            // external rewrite.
            doc.textarea.move_cursor(CursorMove::Jump(0, 0));
        }
        let _ = id;
        self.refresh_typst_diagnostics_for_opened();
        // 1.2.12+ Phase D — external rewrite swapped the
        // body wholesale; cached detection is stale.
        self.detect_paragraph_language();
        self.status = format!(
            "↻ reloaded `{title}` — file changed on disk"
        );
    }

    fn is_streaming(&self) -> bool {
        matches!(
            self.inference.as_ref().map(|i| &i.status),
            Some(InferenceStatus::Streaming)
        )
    }

    /// 1.3.12 DEEP-1 P0 — spawn a background job: refuses if one's already
    /// running, else starts a thread running `work` (which sends progress /
    /// done over the channel) and stows the receiver. Returns whether it
    /// started. `work` must own everything it needs (e.g. a cloned `Store`).
    pub(super) fn start_bg_job(
        &mut self,
        kind: BgJobKind,
        label: impl Into<String>,
        work: impl FnOnce(std::sync::mpsc::Sender<BgMsg>) + Send + 'static,
    ) -> bool {
        if self.bg_job.is_some() {
            self.status = "a background job is already running — let it finish first".into();
            return false;
        }
        let (tx, rx) = std::sync::mpsc::channel();
        let label = label.into();
        std::thread::spawn(move || work(tx));
        self.status = format!("⟳ {label}…");
        self.bg_job = Some(BgJob { rx, label, kind });
        true
    }

    /// Drain the running job's channel each tick: surface progress to the
    /// status line, and on completion dispatch to the kind's handler. Never
    /// blocks. (`drain_bg_messages` does the receiver work; this owns the
    /// borrow dance + dispatch.)
    fn pump_bg_job(&mut self) {
        let Some(job) = self.bg_job.take() else {
            return;
        };
        let (progress, done) = drain_bg_messages(&job.rx);
        if let Some(p) = progress {
            self.status = format!("⟳ {}: {p}", job.label);
        }
        match done {
            Some(result) => {
                let kind = job.kind;
                drop(job);
                self.on_bg_job_done(kind, result);
            }
            None => self.bg_job = Some(job),
        }
    }

    /// Completion dispatch for a finished background job.
    fn on_bg_job_done(&mut self, kind: BgJobKind, result: std::result::Result<String, String>) {
        match kind {
            BgJobKind::DeepRefresh => match result {
                Ok(_) => {
                    // The scans wrote fresh sidecars — refresh the open world
                    // modal (bible / cockpit) and report the new health line.
                    self.refresh_open_world_modal();
                    let summary = crate::cli::world::report_from(
                        &self.store,
                        &self.hierarchy,
                        &self.cfg,
                        &self.layout.root,
                    )
                    .summary();
                    self.status = format!("deep refresh done — {summary}");
                }
                Err(e) => self.status = format!("deep refresh failed: {e}"),
            },
        }
    }

    /// 1.3.12 DEEP-1 P2 — kick off the deep AI world refresh (`Ctrl+V Shift+F`)
    /// on the background-job harness. Pre-flights an LLM provider, then moves a
    /// **clone** of the Store (shares the pool — no DB reopen) + cfg + layout
    /// into the worker, which runs `deep_refresh_shared` and forwards progress.
    fn start_deep_refresh(&mut self) {
        if self.bg_job.is_some() {
            self.status = "a deep refresh is already running".into();
            return;
        }
        if let Err(e) = self.ai.resolve_provider(&self.cfg.llm, None) {
            self.status = format!("deep refresh: {e} — set an LLM provider first");
            return;
        }
        let store = self.store.clone();
        let cfg = self.cfg.clone();
        let layout = self.layout.clone();
        self.start_bg_job(BgJobKind::DeepRefresh, "deep refresh", move |tx| {
            let result = {
                let progress = |s: &str| {
                    let _ = tx.send(BgMsg::Progress(s.to_string()));
                };
                crate::cli::world::deep_refresh_shared(&store, &cfg, &layout, None, &progress)
            };
            let _ = tx.send(BgMsg::Done(
                result
                    .map(|()| "world refresh complete".to_string())
                    .map_err(|e| e.to_string()),
            ));
        });
    }

    /// Rebuild the open story bible / Editorial Pass from freshly-written
    /// sidecars (after a deep refresh). No-op for any other modal.
    fn refresh_open_world_modal(&mut self) {
        if matches!(self.modal, Modal::StoryBible { .. }) {
            let rows = self.build_story_bible_rows();
            if let Modal::StoryBible { rows: r, cursor } = &mut self.modal {
                *cursor = (*cursor).min(rows.len().saturating_sub(1));
                *r = rows;
            }
        } else if matches!(self.modal, Modal::EditorialPass { .. }) {
            // Re-collects from the fresh sidecars (resets cursor/filter).
            self.open_editorial_pass();
        }
    }

    fn pump_inference(&mut self) {
        let Some(inf) = self.inference.as_mut() else {
            return;
        };
        if !matches!(inf.status, InferenceStatus::Streaming) {
            return;
        }
        // Track terminal state of this poll so we can commit to chat history
        // exactly once when the stream completes successfully.
        let mut just_finished = false;
        loop {
            match inf.rx.try_recv() {
                Ok(StreamMsg::Token(t)) => inf.response.push_str(&t),
                Ok(StreamMsg::Done) => {
                    inf.status = InferenceStatus::Done;
                    let elapsed = inf.started_at.elapsed();
                    self.status = format!(
                        "{} responded in {:.1}s",
                        inf.provider,
                        elapsed.as_secs_f32()
                    );
                    just_finished = true;
                    break;
                }
                Ok(StreamMsg::Error(e)) => {
                    inf.status = InferenceStatus::Error(e.clone());
                    self.status = format!("inference error: {e}");
                    break;
                }
                Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
                Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
                    // Task ended without a final message — treat as done so
                    // the assistant turn still gets recorded.
                    if matches!(inf.status, InferenceStatus::Streaming) {
                        inf.status = InferenceStatus::Done;
                        just_finished = true;
                    }
                    break;
                }
            }
        }
        if just_finished {
            // 1.2.21+ FF.4d — if this completion was a fact-check chord,
            // parse its `claim | fact | detail` verdict into navigable
            // findings (cycled by Ctrl+B Shift+J).
            if let Some((target, title)) = self.fact_check_pending.take() {
                let resp = self
                    .inference
                    .as_ref()
                    .map(|i| i.response.clone())
                    .unwrap_or_default();
                let findings = crate::facts_scan::parse_findings(&resp, &title, 0);
                if !findings.is_empty() {
                    self.status = format!(
                        "fact check: {} contradiction(s) — Ctrl+B Shift+J to jump",
                        findings.len(),
                    );
                }
                self.fact_check_nav = FactCheckNav {
                    target: Some(target),
                    findings,
                    idx: usize::MAX,
                };
            }
            // Pair the pending user message with this assistant response
            // and append both to chat_history. Help one-shots leave
            // `pending_chat_user_msg = None`, so they're skipped here.
            let assistant_text = self
                .inference
                .as_ref()
                .map(|i| i.response.clone())
                .unwrap_or_default();
            if let Some(user_msg) = self.pending_chat_user_msg.take() {
                if !assistant_text.trim().is_empty() {
                    // 1.2.6+ — stamp the turns onto the open
                    // paragraph's `ai_memory` if a target was
                    // captured at send time. Persisted via
                    // update_metadata so the buffer survives
                    // restart. Cap is enforced at write time:
                    // oldest turns evict first when length
                    // exceeds `per_paragraph_memory_max_turns`.
                    if let Some(target_id) =
                        self.pending_paragraph_memory_target.take()
                    {
                        self.record_paragraph_ai_memory(
                            target_id,
                            &user_msg,
                            &assistant_text,
                        );
                    }
                    self.chat_history.push(ChatTurn::User(user_msg));
                    self.chat_history
                        .push(ChatTurn::Assistant(assistant_text));
                }
            } else {
                // No pending user msg means a one-shot
                // (Help / F7 / F11 / F12). Discard any stale
                // memory target — those flows don't pollute
                // per-paragraph memory.
                self.pending_paragraph_memory_target = None;
            }
            // 1.2.11+ — Ctrl+B Shift+M rhythm rewrite:
            // auto-open the AI diff modal so the user
            // reviews the response before it lands in
            // the buffer.  The modal carries the
            // snapshot annotation so the apply step
            // preserves the pre-rewrite state under
            // "Sentence rhythm rewrite".
            if let Some(annotation) = self.pending_rewrite_diff.take() {
                let span = self.pending_rewrite_span.take();
                let raw = self
                    .inference
                    .as_ref()
                    .map(|i| i.response.clone())
                    .unwrap_or_default();
                if raw.trim().is_empty() {
                    self.status = "rewrite: model returned empty response".into();
                } else if let Some(range) = span {
                    // 1.3.9 span-scoped fix: the reply is just the replacement
                    // phrase — splice it into the paragraph's char range, then
                    // diff the whole paragraph for context.
                    match self.opened.as_ref().map(|d| d.textarea.lines().join("\n")) {
                        Some(original) => {
                            let replacement = crate::editorial::extract_phrase(&raw);
                            let spliced =
                                crate::editorial::splice_span(&original, range, &replacement);
                            self.open_ai_diff_review_final(
                                InferenceAction::Replace,
                                &spliced,
                                &annotation,
                            );
                        }
                        None => self.status = "rewrite: no paragraph open".into(),
                    }
                } else {
                    self.open_ai_diff_review_with_snapshot(
                        InferenceAction::Replace,
                        &raw,
                        &annotation,
                    );
                }
            }
        } else if matches!(
            self.inference.as_ref().map(|i| &i.status),
            Some(InferenceStatus::Error(_))
        ) && self.pending_rewrite_diff.is_some()
        {
            // Error path: clear the pending flags so a future rewrite
            // starts clean.
            self.pending_rewrite_diff = None;
            self.pending_rewrite_span = None;
        }
    }

    /// 1.2.6+ — append a `(user, assistant)` pair to the open
    /// paragraph's `Node.ai_memory`, persist via
    /// `update_metadata`, and enforce the
    /// `ai.per_paragraph_memory_max_turns` cap by trimming
    /// oldest turns first. Per-paragraph AI memory is an
    /// additive metadata write — failures are logged but never
    /// abort the visible chat-history append above.
    fn record_paragraph_ai_memory(
        &mut self,
        paragraph_id: Uuid,
        user_msg: &str,
        assistant_text: &str,
    ) {
        let cap = self.cfg.ai.per_paragraph_memory_max_turns;
        if cap == 0 {
            return;
        }
        let Some(node) = self.hierarchy.get(paragraph_id).cloned() else {
            return;
        };
        let mut updated = node.clone();
        updated
            .ai_memory
            .push(crate::store::node::AiMemoryTurn {
                role: "user".to_string(),
                text: user_msg.to_owned(),
            });
        updated
            .ai_memory
            .push(crate::store::node::AiMemoryTurn {
                role: "assistant".to_string(),
                text: assistant_text.to_owned(),
            });
        // Trim oldest turns until we're within the cap. The
        // cap counts individual turns; trimming two at a time
        // keeps the buffer pair-aligned.
        while updated.ai_memory.len() > cap {
            updated.ai_memory.remove(0);
            if updated.ai_memory.len() > cap {
                updated.ai_memory.remove(0);
            }
        }
        updated.modified_at = chrono::Utc::now();
        if let Err(e) = self
            .store
            .raw()
            .update_metadata(paragraph_id, updated.to_json())
        {
            tracing::warn!(
                target: "inkhaven::ai_memory",
                uuid = %paragraph_id,
                "record_paragraph_ai_memory: update_metadata failed: {e}",
            );
            return;
        }
        // Reload so the next prompt-send (which reads from
        // `self.hierarchy`) sees the freshly-stamped turns.
        self.reload_hierarchy();
    }

    // -------- key dispatch ------------------------------------------------

    /// Dispatch a crossterm mouse event. Left-click moves focus to the
    /// clicked pane and positions the cursor inside it where possible.
    /// Scroll wheel scrolls the pane under the pointer. Other kinds
    /// (middle-click, drag, motion) are ignored for now.
    ///
    /// Modals / overlays (file picker, prompt picker, modal stack) eat
    /// mouse input — clicking through a modal feels wrong and the
    /// keyboard flow for those is well-trodden. We early-return if any
    /// modal is up so the click can't accidentally focus a pane that's
    /// hidden behind the floating panel.
    fn handle_mouse(&mut self, ev: MouseEvent) {
        // 1.2.8+ — modal dispatch FIRST.  Previously
        // returned early for any modal; now route the
        // wheel into modals that have meaningful scroll
        // state (shell pane turn buffer, HJSON editor
        // cursor, picker cursors).  Picker overlays still
        // dominate — handle them inline.
        if self.show_results_overlay || self.show_prompt_picker {
            return;
        }
        if !matches!(self.modal, Modal::None) {
            self.handle_mouse_in_modal(ev);
            return;
        }
        let (col, row) = (ev.column, ev.row);
        let pane = self.pane_at(col, row);
        match ev.kind {
            MouseEventKind::Down(MouseButton::Left) => {
                if let Some(target) = pane {
                    if self.focus != target {
                        self.change_focus(target);
                    }
                    match target {
                        Focus::Tree => self.mouse_position_tree(row),
                        Focus::Editor => self.mouse_position_editor(col, row),
                        _ => {}
                    }
                }
            }
            MouseEventKind::ScrollUp => match pane {
                Some(Focus::Tree) => self.move_cursor(-3),
                Some(Focus::Editor) => self.mouse_scroll_editor(-3),
                Some(Focus::Ai) => {
                    // Wheel up = show older content
                    // (chat_history_scroll grows backward).
                    self.chat_history_scroll =
                        self.chat_history_scroll.saturating_add(3);
                }
                _ => {}
            },
            MouseEventKind::ScrollDown => match pane {
                Some(Focus::Tree) => self.move_cursor(3),
                Some(Focus::Editor) => self.mouse_scroll_editor(3),
                Some(Focus::Ai) => {
                    self.chat_history_scroll =
                        self.chat_history_scroll.saturating_sub(3);
                }
                _ => {}
            },
            _ => {}
        }
    }

    /// 1.2.8+ — mouse-wheel dispatcher for the active modal.
    /// Tier-1 modals (shell pane, HJSON editor, kill-ring
    /// picker, fuzzy paragraph picker) get explicit wheel
    /// behaviour; everything else falls through silently.
    /// Step is 3 entries / lines per wheel-tick, matching
    /// the main UI panes.
    fn handle_mouse_in_modal(&mut self, ev: MouseEvent) {
        use tui_textarea::CursorMove;
        let dir: i32 = match ev.kind {
            MouseEventKind::ScrollUp => -1,
            MouseEventKind::ScrollDown => 1,
            _ => return,
        };
        const STEP: usize = 3;
        match &mut self.modal {
            // Shell pane: wheel adjusts the turn-buffer
            // scroll directly.  Up wheel = scroll back =
            // increase the offset; down wheel = forward =
            // decrease.
            Modal::ShellPane { scroll, .. } => {
                if dir < 0 {
                    *scroll = scroll.saturating_add(STEP);
                } else {
                    *scroll = scroll.saturating_sub(STEP);
                }
            }
            // HJSON editor: walk the textarea cursor up /
            // down N lines.  Selection state is preserved
            // (no cancel_selection here — wheel scrolling
            // shouldn't dump an in-progress selection).
            Modal::HjsonEditor { textarea, .. } => {
                let cmove = if dir < 0 {
                    CursorMove::Up
                } else {
                    CursorMove::Down
                };
                for _ in 0..STEP {
                    textarea.move_cursor(cmove);
                }
            }
            // Kill-ring picker: move the cursor up/down
            // within the ring.  Clamp to [0, len-1].
            Modal::KillRingPicker { cursor } => {
                let len = self.kill_ring.len();
                if len == 0 {
                    return;
                }
                let max = len - 1;
                if dir < 0 {
                    *cursor = cursor.saturating_sub(STEP);
                } else {
                    *cursor = (*cursor + STEP).min(max);
                }
            }
            // Fuzzy paragraph picker: move cursor + keep
            // it visible by adjusting scroll.  Same step.
            Modal::FuzzyParagraphPicker {
                cursor,
                scroll,
                entries,
                ..
            } => {
                let len = entries.len();
                if len == 0 {
                    return;
                }
                let max = len - 1;
                if dir < 0 {
                    *cursor = cursor.saturating_sub(STEP);
                } else {
                    *cursor = (*cursor + STEP).min(max);
                }
                // Keep scroll roughly aligned with cursor.
                if *cursor < *scroll {
                    *scroll = *cursor;
                }
            }
            _ => {}
        }
    }

    /// Map a terminal coordinate to the pane that owns it, if any. Uses
    /// the rectangles cached by the most recent `draw()`.
    fn pane_at(&self, col: u16, row: u16) -> Option<Focus> {
        if rect_contains(self.layout_tree, col, row) {
            return Some(Focus::Tree);
        }
        if rect_contains(self.layout_editor, col, row) {
            return Some(Focus::Editor);
        }
        if rect_contains(self.layout_ai, col, row) {
            return Some(Focus::Ai);
        }
        if rect_contains(self.layout_search, col, row) {
            return Some(Focus::SearchBar);
        }
        if rect_contains(self.layout_ai_prompt, col, row) {
            return Some(Focus::AiPrompt);
        }
        None
    }

    /// Move the tree cursor to whatever row was clicked. Accounts for
    /// the 1-row top border and the current scroll offset.
    fn mouse_position_tree(&mut self, row: u16) {
        if self.rows.is_empty() {
            return;
        }
        let body_y = self.layout_tree.y.saturating_add(1);
        if row < body_y {
            return;
        }
        let row_in_view = (row - body_y) as usize;
        let idx = self.tree_scroll + row_in_view;
        if idx < self.rows.len() {
            self.tree_cursor = idx;
        }
    }

    /// Position the editor cursor under the click. Accounts for the
    /// pane border (1 row top, 1 col left), the line-number gutter, and
    /// the current scroll. Wrapped-line clicks degrade gracefully by
    /// snapping to the closest source row.
    fn mouse_position_editor(&mut self, col: u16, row: u16) {
        let Some(doc) = self.opened.as_mut() else {
            return;
        };
        let inner_x = self.layout_editor.x.saturating_add(1);
        let inner_y = self.layout_editor.y.saturating_add(1);
        if row < inner_y || col < inner_x {
            return;
        }
        // Gutter width: digits in line count + 1 trailing space (see
        // `digit_count` + `gutter_width` in the editor renderer).
        let total_lines = doc.textarea.lines().len().max(1);
        let lineno_chars = digit_count(total_lines);
        let gutter = (lineno_chars + 1) as u16;
        let inner_cursor_x = inner_x.saturating_add(gutter);
        // Click inside the gutter or borders → ignore.
        if col < inner_cursor_x {
            return;
        }
        let rel_row = (row - inner_y) as usize;
        let rel_col = (col - inner_cursor_x) as usize;
        let src_row = (doc.scroll_row + rel_row).min(total_lines - 1);
        let line_len = doc
            .textarea
            .lines()
            .get(src_row)
            .map_or(0, |s| s.chars().count());
        let src_col = (doc.scroll_col + rel_col).min(line_len);
        doc.textarea
            .move_cursor(tui_textarea::CursorMove::Jump(src_row as u16, src_col as u16));
        doc.last_activity = std::time::Instant::now();
    }

    /// Scroll the editor by `delta` source rows (positive = down).
    /// Updates the cursor too so the visible window doesn't snap back on
    /// the next render — the renderer keeps the cursor in view, which
    /// would otherwise undo a pure scroll-only adjustment.
    fn mouse_scroll_editor(&mut self, delta: i32) {
        let Some(doc) = self.opened.as_mut() else {
            return;
        };
        let total = doc.textarea.lines().len().max(1);
        if delta < 0 {
            let n = (-delta) as usize;
            doc.scroll_row = doc.scroll_row.saturating_sub(n);
            use tui_textarea::CursorMove;
            for _ in 0..n {
                doc.textarea.move_cursor(CursorMove::Up);
            }
        } else {
            let n = delta as usize;
            doc.scroll_row = (doc.scroll_row + n).min(total.saturating_sub(1));
            use tui_textarea::CursorMove;
            for _ in 0..n {
                doc.textarea.move_cursor(CursorMove::Down);
            }
        }
        doc.last_activity = std::time::Instant::now();
    }

    fn handle_key(&mut self, key: KeyEvent) -> Result<bool> {
        // 1.2.8+ — ConfirmQuit modal swallows its own keys:
        // Y / Enter proceed with the existing quit flow;
        // N / Esc cancel + close the modal.  Anything else
        // is ignored (so a misclick on the keyboard doesn't
        // dismiss the modal).
        if matches!(self.modal, Modal::ConfirmQuit) {
            match key.code {
                KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
                    self.modal = Modal::None;
                    return Ok(self.request_quit());
                }
                KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
                    self.modal = Modal::None;
                    self.status = "quit cancelled".into();
                    return Ok(false);
                }
                _ => return Ok(false),
            }
        }

        // 1.2.20+ Phase G — uncommitted-changes quit guard.
        // The open buffer was already saved by `request_quit`
        // before this modal opened, so Y finalises directly
        // (saving the session) rather than re-running the git
        // check and looping.
        if matches!(self.modal, Modal::ConfirmQuitUncommitted { .. }) {
            match key.code {
                KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
                    self.modal = Modal::None;
                    let _ = self.save_session();
                    return Ok(true);
                }
                KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
                    self.modal = Modal::None;
                    self.status = "quit cancelled — commit or stash first".into();
                    return Ok(false);
                }
                _ => return Ok(false),
            }
        }

        // 1.2.9+ — TtsUnavailable: any key dismisses.
        if matches!(self.modal, Modal::TtsUnavailable { .. }) {
            self.modal = Modal::None;
            return Ok(false);
        }
        // 1.2.9+ — TtsPlayback: any key stops the engine
        // and closes the modal.  The render loop polls
        // for natural end-of-speech in `tts_poll_playback`.
        if matches!(self.modal, Modal::TtsPlayback { .. }) {
            self.tts_stop_playback();
            return Ok(false);
        }

        // Hard quit works from anywhere, including inside a modal.
        if key.modifiers.contains(KeyModifiers::CONTROL)
            && matches!(key.code, KeyCode::Char('q') | KeyCode::Char('Q'))
        {
            // 1.2.8+ — gate the quit behind a confirmation
            // modal when `editor.confirm_quit = true`.
            // Skip when a modal is already open (the user
            // is mid-workflow; treat Ctrl+Q in-modal as the
            // unconditional escape hatch it always was).
            if self.cfg.editor.confirm_quit
                && matches!(self.modal, Modal::None)
            {
                self.modal = Modal::ConfirmQuit;
                self.status =
                    "quit inkhaven? Y/Enter confirm · N/Esc cancel".into();
                return Ok(false);
            }
            return Ok(self.request_quit());
        }

        // Modal eats every other key.
        if !matches!(self.modal, Modal::None) {
            return self.handle_modal_key(key);
        }

        // Ctrl+1..5 — direct focus jumps. Most terminals send these as
        // Char(digit) + CONTROL. But Ctrl+2 on US-layout terminals is often
        // re-encoded as Ctrl+@ → KeyCode::Char('@') with CONTROL, or as
        // KeyCode::Null. Try every variant we've seen in the wild.
        if key.modifiers.contains(KeyModifiers::CONTROL)
            && !key
                .modifiers
                .intersects(KeyModifiers::ALT | KeyModifiers::SUPER)
        {
            let target = match key.code {
                KeyCode::Char('1') => Some(Focus::Editor),
                // Ctrl+2 alternates:
                KeyCode::Char('2') | KeyCode::Char('@') => Some(Focus::Tree),
                KeyCode::Char('3') => Some(Focus::Ai),
                KeyCode::Char('4') => Some(Focus::SearchBar),
                KeyCode::Char('5') => Some(Focus::AiPrompt),
                // Ctrl+T also focuses Tree — mnemonic and not terminal-eaten.
                KeyCode::Char('t') | KeyCode::Char('T') => Some(Focus::Tree),
                _ => None,
            };
            if let Some(focus) = target {
                self.change_focus(focus);
                return Ok(false);
            }
        }
        // KeyCode::Null with no modifiers is what some terminals report for
        // Ctrl+2 / Ctrl+Space. Catch that separately because the inner block
        // requires the CONTROL modifier flag.
        if matches!(key.code, KeyCode::Null) {
            self.change_focus(Focus::Tree);
            return Ok(false);
        }

        // View-prefix dispatch (Ctrl+V by default, 1.2.4+).
        // Captures the next keystroke. Resolution goes through
        // the binding table now (Layer::ViewSub), so HJSON
        // `keys.bindings` + `ink.key.*` can rebind every chord
        // under the prefix.
        if self.view_pending {
            self.handle_view_action(key);
            return Ok(false);
        }
        if let Some(view_prefix) = self.keymap.view_prefix {
            if view_prefix.matches(&key) {
                self.view_pending = true;
                self.status = super::keybind::read().view_hint(self.focus);
                return Ok(false);
            }
        }

        // Bund-meta dispatch (Ctrl+Z by default). Mirrors the
        // meta-prefix machinery below — same state-machine shape,
        // different action table. Intercept BEFORE tui-textarea
        // sees the key so its default Ctrl+Z=undo binding stays
        // dormant.
        if self.bund_pending {
            self.handle_bund_action(key);
            return Ok(false);
        }
        if let Some(bund_prefix) = self.keymap.bund_prefix {
            if bund_prefix.matches(&key) {
                self.bund_pending = true;
                self.status = super::keybind::read().bund_hint(self.focus);
                return Ok(false);
            }
        }

        // Meta-prefix dispatch. If we're already inside meta mode, the next
        // key is the action selector. Otherwise check whether THIS key is
        // the meta prefix and enter the mode.
        if self.meta_pending {
            self.handle_meta_action(key);
            return Ok(false);
        }
        if self.keymap.meta_prefix.matches(&key) {
            self.meta_pending = true;
            // The meta action table is pane-specific (see dispatch_meta_*),
            // so the hint shown in the status bar should match the focused
            // pane. Generic suffix (· H help · Esc cancel) is shared.
            // Build the meta-hint from the live binding table so
            // user overlays (HJSON + ink.key.*) show up in the
            // status bar automatically.
            self.status = super::keybind::read().meta_hint(self.focus);
            return Ok(false);
        }


        // 1.2.4+: F-keys + every top-level (no-prefix) chord
        // flow through the `top_level` binding table. The table
        // is pane-aware via Scope, so F-keys that only made
        // sense in one pane (F2 rename / F3 file picker /
        // F4-F6 editor) keep their per-pane behaviour without
        // hardcoded match arms. The user can rebind any of
        // them via HJSON `keys.bindings` (single-token chord
        // strings route to TopLevel) or runtime
        // `ink.key.bind`.
        if let Some(action) = super::keybind::read().resolve_top_level(&key, self.focus) {
            if !matches!(action, super::keybind::Action::None) {
                self.run_action(action);
                return Ok(false);
            }
        }

        // AI-fullscreen Ctrl+C toggles "Chat selection mode" — the
        // editor's Ctrl+C still does clipboard-copy via the focus
        // dispatch, but in this layout the editor isn't visible and
        // the chord is reclaimed for navigating chat turns.
        if self.ai_fullscreen
            && key.modifiers.contains(KeyModifiers::CONTROL)
            && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
            && matches!(self.modal, Modal::None)
        {
            self.toggle_chat_selection_mode();
            return Ok(false);
        }

        // When chat-selection is active: Up / Down step turns, `c` /
        // `C` copies to clipboard, `t` / `T` inserts at the editor
        // cursor, Esc / Enter exits.
        if self.ai_fullscreen && self.chat_selection.is_some() && matches!(self.modal, Modal::None) {
            let plain = !key
                .modifiers
                .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER);
            match key.code {
                KeyCode::Up if plain => {
                    self.chat_selection_step(-1);
                    return Ok(false);
                }
                KeyCode::Down if plain => {
                    self.chat_selection_step(1);
                    return Ok(false);
                }
                KeyCode::Home if plain => {
                    self.chat_selection_jump(0);
                    return Ok(false);
                }
                KeyCode::End if plain => {
                    self.chat_selection_jump(usize::MAX);
                    return Ok(false);
                }
                KeyCode::Char('c') | KeyCode::Char('C') if plain => {
                    self.chat_selection_copy();
                    return Ok(false);
                }
                KeyCode::Char('t') | KeyCode::Char('T') if plain => {
                    self.chat_selection_into_editor();
                    return Ok(false);
                }
                KeyCode::Esc | KeyCode::Enter => {
                    self.chat_selection = None;
                    self.status = "chat selection mode off".into();
                    return Ok(false);
                }
                _ => {}
            }
        }

        // AI-fullscreen Esc with an active chat search → clear the
        // search (drop highlights + scroll back to the bottom-pin).
        // Routed before the focus handlers so AI-prompt Esc doesn't
        // grab it.
        if self.ai_fullscreen
            && self.chat_search.is_some()
            && matches!(key.code, KeyCode::Esc)
            && matches!(self.modal, Modal::None)
        {
            self.chat_search = None;
            self.chat_history_scroll = 0;
            self.status = "chat search cleared".into();
            return Ok(false);
        }

        // AI-fullscreen Ctrl+F → chat-history search modal. Intercepted
        // before the editor-pane Ctrl+F handler so the chord behaves
        // contextually based on layout, not focus.
        if self.ai_fullscreen
            && key.modifiers.contains(KeyModifiers::CONTROL)
            && matches!(key.code, KeyCode::Char('f') | KeyCode::Char('F'))
        {
            self.open_chat_search_prompt();
            return Ok(false);
        }
        // Ctrl+X advances to the next (older) match while a chat
        // search is active. No-op when no search is running.
        if self.ai_fullscreen
            && key.modifiers.contains(KeyModifiers::CONTROL)
            && matches!(key.code, KeyCode::Char('x') | KeyCode::Char('X'))
            && self.chat_search.is_some()
        {
            self.advance_chat_search();
            return Ok(false);
        }

        // AI-fullscreen-only: PageUp / PageDown scroll the chat history
        // pane regardless of which sub-pane has focus (the user is
        // typically focused on the AI prompt). Up / Down do the same
        // one line at a time for fine-grained reading. Intercepted
        // here so the AI prompt's own input handler never sees these
        // keys in this layout. The non-fullscreen path leaves them
        // untouched.
        //
        // Exception: when the `/` prompt-library picker is open, Up /
        // Down still navigate the picker — that's the picker's only
        // way to move the selection.
        if self.ai_fullscreen {
            if self.keymap.page_up.matches(&key) {
                self.chat_history_scroll = self.chat_history_scroll.saturating_add(10);
                return Ok(false);
            }
            if self.keymap.page_down.matches(&key) {
                self.chat_history_scroll = self.chat_history_scroll.saturating_sub(10);
                return Ok(false);
            }
            if !self.show_prompt_picker {
                if matches!(key.code, KeyCode::Up) {
                    self.chat_history_scroll = self.chat_history_scroll.saturating_add(1);
                    return Ok(false);
                }
                if matches!(key.code, KeyCode::Down) {
                    self.chat_history_scroll = self.chat_history_scroll.saturating_sub(1);
                    return Ok(false);
                }
            }
        }

        // F9 / F10 / F7 / F1 et al. now flow through the
        // top_level binding-table dispatch above. The hardcoded
        // match arms were removed in the 1.2.4 F-key migration.

        // Save works from anywhere as long as a doc is open.
        if self.keymap.save.matches(&key) && self.opened.is_some() {
            self.save_current()?;
            return Ok(false);
        }

        // Focus jumps from anywhere.
        if self.keymap.search.matches(&key) {
            self.change_focus(Focus::SearchBar);
            return Ok(false);
        }
        if self.keymap.ai_prompt.matches(&key) {
            self.change_focus(Focus::AiPrompt);
            return Ok(false);
        }

        // Tab cycling everywhere except when typing into a buffer.
        let in_editor_with_doc = self.focus == Focus::Editor && self.opened.is_some();
        let cycling_blocked = self.focus.is_input() || in_editor_with_doc;
        if !cycling_blocked {
            if self.keymap.next_pane.matches(&key) {
                self.change_focus(self.focus.next());
                return Ok(false);
            }
            if self.keymap.prev_pane.matches(&key) {
                self.change_focus(self.focus.prev());
                return Ok(false);
            }
        } else if in_editor_with_doc
            && (self.keymap.next_pane.matches(&key) || self.keymap.prev_pane.matches(&key))
        {
            // Inside an active editor, Tab cycles focus too — but only when
            // the user really meant to (no other modifiers were on). If we
            // didn't intercept here, Tab would insert a literal tab via
            // tui-textarea.
            //
            // Similar-paragraph mode special case: when the AI pane
            // has been replaced by a second editor (`self.secondary
            // .is_some()`), Tab inside the editor pane toggles
            // keyboard focus between the two editor panes instead
            // of cycling to a non-existent AI pane. Shift+Tab does
            // the same — there's only one "other pane" to flip to.
            // 1.2.12+ Phase C — the same path covers split-view
            // (Shift+F4); the status echo says "split" instead of
            // "similar" so the user knows which layout they're in.
            if self.secondary.is_some() {
                self.secondary_focused = !self.secondary_focused;
                let mode = if self.split_view { "split" } else { "similar" };
                self.status = if self.secondary_focused {
                    format!("{mode}: right editor focused")
                } else {
                    format!("{mode}: left editor focused")
                };
                return Ok(false);
            }
            let next = if self.keymap.next_pane.matches(&key) {
                self.focus.next()
            } else {
                self.focus.prev()
            };
            self.change_focus(next);
            return Ok(false);
        }

        match self.focus {
            Focus::Tree => self.handle_tree_key(key),
            Focus::Editor => self.handle_editor_key(key),
            Focus::Ai => self.handle_passive_key(key),
            Focus::SearchBar => self.handle_input_key(key, true),
            Focus::AiPrompt => self.handle_input_key(key, false),
        }
    }

    fn handle_tree_key(&mut self, key: KeyEvent) -> Result<bool> {
        // For plain-letter / punctuation shortcuts we ignore the SHIFT modifier
        // (uppercase letters require Shift on most layouts) but reject Ctrl /
        // Alt / Super so e.g. Ctrl+A doesn't accidentally trigger Add-subchapter.
        let plain = !key
            .modifiers
            .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER);

        match key.code {
            KeyCode::Char('q') | KeyCode::Char('Q') if plain => return Ok(self.request_quit()),
            // Esc cycles Tree → Search bar (third leg of the
            // Editor → Tree → Search → Editor rotation).
            // 1.2.4+: Esc exits link-pick mode (returning to
            // the editor) instead of cycling focus to the
            // search bar. Also clears any multi-select marks
            // so the next chord operates on the cursor row,
            // not stale marks.
            KeyCode::Esc => {
                if self.link_pick_for.is_some() {
                    self.link_pick_for = None;
                    self.status = "link cancelled".into();
                    self.change_focus(Focus::Editor);
                } else if !self.tree_marked.is_empty() {
                    let n = self.tree_marked.len();
                    self.tree_marked.clear();
                    self.status =
                        format!("multi-select cleared ({n} paragraph(s))");
                } else {
                    self.change_focus(Focus::SearchBar);
                }
            }
            // 1.2.4+: Space toggles multi-select on the
            // cursor row (paragraphs only — branches don't
            // accept the bulk operations).
            KeyCode::Char(' ') if plain => {
                if let Some((id, _)) = self.rows.get(self.tree_cursor) {
                    let id = *id;
                    if let Some(node) = self.hierarchy.get(id) {
                        if node.kind == NodeKind::Paragraph {
                            if self.tree_marked.remove(&id) {
                                self.status = format!(
                                    "unmarked · {} marked",
                                    self.tree_marked.len()
                                );
                            } else {
                                self.tree_marked.insert(id);
                                self.status = format!(
                                    "marked · {} marked",
                                    self.tree_marked.len()
                                );
                            }
                        } else {
                            self.status =
                                "multi-select only marks paragraphs".into();
                        }
                    }
                }
            }
            // F2 (rename) and F3 (file picker) now flow through
            // the top_level binding-table dispatch in handle_key.
            KeyCode::Up => self.move_cursor(-1),
            KeyCode::Down => self.move_cursor(1),
            KeyCode::Home => self.tree_cursor = 0,
            KeyCode::End => {
                if !self.rows.is_empty() {
                    self.tree_cursor = self.rows.len() - 1;
                }
            }
            // 1.2.4+: when link-pick mode is active, Enter on a
            // tree row links the row's paragraph to the
            // pick-mode owner rather than opening it for editing.
            // 1.2.12+ Phase B: Shift+Enter pins the focused
            // paragraph to the split-view secondary slot
            // instead of opening it into the primary.
            KeyCode::Enter => {
                if self.link_pick_for.is_some() {
                    self.commit_link_pick();
                } else if key.modifiers.contains(KeyModifiers::SHIFT) {
                    if let Some(&(id, _)) = self.rows.get(self.tree_cursor) {
                        self.dispatch_picker_accept(id, true);
                    }
                } else {
                    self.open_selected()?;
                }
            }

            // Right arrow: expand cursor's branch (no-op for leaves).
            // Left arrow: collapse cursor's branch if expanded, else move
            // cursor to its parent in the hierarchy. Same semantics as the
            // F3 file picker (§11 in KEYBINDING.md).
            KeyCode::Right => self.tree_expand_at_cursor(),
            KeyCode::Left => self.tree_collapse_or_step_out(),

            // Tree-pane add shortcuts. These exist alongside the global
            // Ctrl+Shift+* chords because terminals and multiplexers
            // commonly eat those (Ctrl+S = XOFF, tmux prefix, etc.).
            KeyCode::Char('B') | KeyCode::Char('b') if plain => {
                self.open_add_modal(NodeKind::Book);
            }
            // C/A/+ append at the end of the parent's children;
            // V/S/P insert immediately after the cursor's same-kind ancestor.
            KeyCode::Char('C') | KeyCode::Char('c') if plain => {
                self.open_add_modal(NodeKind::Chapter);
            }
            KeyCode::Char('V') | KeyCode::Char('v') if plain => {
                self.open_add_modal_after(NodeKind::Chapter);
            }
            KeyCode::Char('A') | KeyCode::Char('a') if plain => {
                self.open_add_modal(NodeKind::Subchapter);
            }
            KeyCode::Char('S') | KeyCode::Char('s') if plain => {
                self.open_add_modal_after(NodeKind::Subchapter);
            }
            KeyCode::Char('+') if plain => {
                self.open_add_modal(NodeKind::Paragraph);
            }
            KeyCode::Char('P') | KeyCode::Char('p') if plain => {
                self.open_add_modal_after(NodeKind::Paragraph);
            }

            // Kind-specific delete: D for branches, - for paragraphs. This is
            // a safety feature — `-` won't nuke a whole chapter by accident.
            KeyCode::Char('D') | KeyCode::Char('d') if plain => self.delete_branch_only(),
            KeyCode::Char('-') if plain => self.delete_paragraph_only(),

            // Sibling reorder, plain-letter form. Equivalent to the meta-
            // prefix chord `Ctrl+B ↑` / `Ctrl+B ↓` but reachable without a
            // chord — handy when reorganising a long list of paragraphs.
            KeyCode::Char('U') | KeyCode::Char('u') if plain => {
                self.move_current(MoveDir::Up);
            }
            KeyCode::Char('J') | KeyCode::Char('j') if plain => {
                self.move_current(MoveDir::Down);
            }

            // Z collapses the cursor's enclosing subchapter; X collapses
            // every expanded branch in the tree. Both rebuild the row list
            // so the view updates immediately.
            KeyCode::Char('Z') | KeyCode::Char('z') if plain => {
                self.collapse_enclosing_subchapter();
            }
            KeyCode::Char('X') | KeyCode::Char('x') if plain => {
                self.collapse_all_branches();
            }

            // 1.2.4+: tree T cycles the type of the cursor row
            // (or every marked paragraph, when multi-select is
            // active). Same ladder as Ctrl+B M:
            // Paragraph(typst) → Paragraph(hjson) → Script(bund).
            KeyCode::Char('T') | KeyCode::Char('t') if plain => {
                if !self.tree_marked.is_empty() {
                    self.cycle_leaf_type_bulk();
                } else {
                    self.cycle_leaf_type();
                }
            }
            // 1.2.4+: tree O cycles paragraph status. Mirrors
            // Ctrl+B R; honours multi-select for bulk status
            // transitions.
            KeyCode::Char('O') | KeyCode::Char('o') if plain => {
                self.cycle_paragraph_status();
            }
            // 1.2.5+: tree g opens the tag picker for the
            // marked set (or the cursor row when no marks).
            // Same modal as Ctrl+B ], but applies the picked
            // tags across every selected paragraph at once.
            KeyCode::Char('G') | KeyCode::Char('g') if plain => {
                self.open_tag_picker_for_tree_selection();
            }

            _ if self.keymap.page_up.matches(&key) => self.move_cursor(-10),
            _ if self.keymap.page_down.matches(&key) => self.move_cursor(10),
            _ => {}
        }
        Ok(false)
    }

    fn rebuild_rows_preserving_cursor(&mut self) {
        let prev_id = self.rows.get(self.tree_cursor).map(|(id, _)| *id);
        self.rows = self
            .hierarchy
            .flatten_with_collapsed(&self.collapsed_nodes)
            .into_iter()
            .map(|(n, d)| (n.id, d))
            .collect();
        if let Some(id) = prev_id {
            if let Some(i) = self.rows.iter().position(|(rid, _)| *rid == id) {
                self.tree_cursor = i;
                return;
            }
        }
        if !self.rows.is_empty() {
            self.tree_cursor = self.tree_cursor.min(self.rows.len() - 1);
        } else {
            self.tree_cursor = 0;
        }
    }

    fn delete_branch_only(&mut self) {
        let Some(&(id, _)) = self.rows.get(self.tree_cursor) else {
            self.status = "nothing selected".into();
            return;
        };
        let Some(node) = self.hierarchy.get(id) else {
            return;
        };
        if node.kind == NodeKind::Paragraph {
            self.status = format!(
                "`{}` is a paragraph — press `-` (or Ctrl+B then D) to delete it",
                node.title
            );
            return;
        }
        self.open_delete_modal();
    }

    fn delete_paragraph_only(&mut self) {
        let Some(&(id, _)) = self.rows.get(self.tree_cursor) else {
            self.status = "nothing selected".into();
            return;
        };
        let Some(node) = self.hierarchy.get(id) else {
            return;
        };
        if node.kind != NodeKind::Paragraph {
            self.status = format!(
                "`{}` is a {} — press `D` (or Ctrl+B then D) to delete it",
                node.title,
                node.kind.as_str()
            );
            return;
        }
        self.open_delete_modal();
    }

    /// Entry point for the editor pane. When in similar-paragraph
    /// mode + the secondary pane has keyboard focus
    /// (`self.secondary_focused`), we swap `opened ↔ secondary` so
    /// every downstream handler — all of which target
    /// `self.opened` — naturally operates on the right-pane doc.
    /// Swap back after the call returns. This keeps the 100+
    /// existing editor key handlers unchanged.
    fn handle_editor_key(&mut self, key: KeyEvent) -> Result<bool> {
        if self.secondary_focused && self.secondary.is_some() {
            std::mem::swap(&mut self.opened, &mut self.secondary);
            let r = self.handle_editor_key_inner(key);
            std::mem::swap(&mut self.opened, &mut self.secondary);
            return r;
        }
        self.handle_editor_key_inner(key)
    }

    fn handle_editor_key_inner(&mut self, key: KeyEvent) -> Result<bool> {
        if self.opened.is_none() {
            if matches!(key.code, KeyCode::Char('q') | KeyCode::Char('Q')) {
                return Ok(self.request_quit());
            }
            return Ok(false);
        }

        // Stamp last_activity so idle autosave only fires after the user
        // actually pauses. Done first so every branch below benefits.
        if let Some(doc) = self.opened.as_mut() {
            doc.last_activity = std::time::Instant::now();
        }

        // Read-only gate (Help subtree): allow navigation, search, copy, and
        // focus-related chords; refuse anything that would touch the buffer
        // or the store.
        if self
            .opened
            .as_ref()
            .is_some_and(|d| d.read_only)
            && !is_read_only_safe_key(&key)
        {
            self.status = "Help is read-only".into();
            return Ok(false);
        }

        // 1.2.8+ — Help paragraphs render through
        // `markdown::render` instead of the source view, so
        // moving the textarea cursor (the default route for
        // arrow / PageUp / PageDown / Home / End) is
        // invisible to the user.  Intercept those keys
        // BEFORE the textarea catches them and adjust
        // `opened.scroll_row` directly so the rendered
        // viewport actually moves.  Only fires for the
        // exact read-only+markdown combo the renderer
        // recognises; other read-only views (snapshots,
        // diffs) keep the existing source-cursor behaviour.
        let is_help_rendered = self.opened.as_ref().is_some_and(|d| {
            d.read_only
                && d.content_type.as_deref() == Some("markdown")
        });
        if is_help_rendered {
            // The render path clamps scroll_row at draw
            // time, so we don't need to know the rendered
            // line count here.  Page step = visible
            // editor height; line step = 1.
            let page = self.layout_editor.height.saturating_sub(2) as usize;
            let page = page.max(3);
            let doc = self
                .opened
                .as_mut()
                .expect("opened checked by is_help_rendered");
            match key.code {
                KeyCode::Up => {
                    doc.scroll_row = doc.scroll_row.saturating_sub(1);
                    return Ok(false);
                }
                KeyCode::Down => {
                    doc.scroll_row = doc.scroll_row.saturating_add(1);
                    return Ok(false);
                }
                KeyCode::PageUp => {
                    doc.scroll_row = doc.scroll_row.saturating_sub(page);
                    return Ok(false);
                }
                KeyCode::PageDown => {
                    doc.scroll_row = doc.scroll_row.saturating_add(page);
                    return Ok(false);
                }
                KeyCode::Home => {
                    doc.scroll_row = 0;
                    return Ok(false);
                }
                KeyCode::End => {
                    // Set to a large value; the render-time
                    // clamp drops it back to (total - height).
                    doc.scroll_row = usize::MAX / 2;
                    return Ok(false);
                }
                // Left/Right have no meaning in the rendered
                // viewer (the renderer hard-wraps long
                // lines).  Swallow them so they don't
                // accidentally hit the textarea below.
                KeyCode::Left | KeyCode::Right => {
                    return Ok(false);
                }
                _ => {}
            }
        }

        // Typewriter SFX — plain Enter (end-of-line click). Fires after
        // the read-only gate so refused keystrokes in Help don't click.
        // No-op when sound is disabled or the host has no audio device.
        if matches!(key.code, KeyCode::Enter)
            && !key
                .modifiers
                .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
        {
            if let Some(sp) = &self.sound {
                sp.play_enter();
            }
        }

        // F3 / F5 / F6 now resolve through the top_level binding
        // table at the top of handle_key (1.2.4+ migration).
        // Ctrl+F open find, Ctrl+X "repeat" (advance / replace+advance),
        // Ctrl+R open replace dialog or "replace all" when already in
        // replace mode.
        let ctrl_no_shift = key.modifiers.contains(KeyModifiers::CONTROL)
            && !key.modifiers.contains(KeyModifiers::SHIFT)
            && !key.modifiers.intersects(KeyModifiers::ALT | KeyModifiers::SUPER);
        if ctrl_no_shift {
            // Ctrl+X is "Repeat" only while a search is active. Otherwise
            // it falls through (currently no-op — was cut, now Ctrl+K).
            if matches!(key.code, KeyCode::Char('x') | KeyCode::Char('X'))
                && self
                    .opened
                    .as_ref()
                    .map_or(false, |d| d.search.is_some())
            {
                self.search_advance_or_replace();
                return Ok(false);
            }
            match key.code {
                KeyCode::Char('f') | KeyCode::Char('F') => {
                    self.open_find_modal(false);
                    return Ok(false);
                }
                KeyCode::Char('r') | KeyCode::Char('R') => {
                    if self
                        .opened
                        .as_ref()
                        .and_then(|d| d.search.as_ref())
                        .is_some_and(|s| s.replace_with.is_some())
                    {
                        self.replace_all_remaining();
                    } else {
                        self.open_find_modal(true);
                    }
                    return Ok(false);
                }
                _ => {}
            }
        }
        // Esc in editor: first press clears an active in-buffer search (the
        // Ctrl+F flow); second press cycles focus → Tree. The Editor/Tree/
        // Search cycle is Editor → Tree → Search → Editor.
        if matches!(key.code, KeyCode::Esc) {
            if self.opened.as_ref().is_some_and(|d| d.search.is_some()) {
                if let Some(doc) = self.opened.as_mut() {
                    doc.search = None;
                }
                self.status = "search cleared".into();
                return Ok(false);
            }
            self.change_focus(Focus::Tree);
            return Ok(false);
        }

        // F4 / Ctrl+F4 resolve through the top_level binding
        // table at the top of handle_key.

        // Ctrl+H / Ctrl+J scroll the lower (read-only) pane while split is
        // open. Without split, they fall through to normal editor handling
        // (tui-textarea / our backspace-word / etc.).
        let split_active = self.opened.as_ref().is_some_and(|d| d.split.is_some());
        if split_active && key.modifiers.contains(KeyModifiers::CONTROL) {
            match key.code {
                KeyCode::Char('h') | KeyCode::Char('H') => {
                    self.scroll_split_up();
                    return Ok(false);
                }
                KeyCode::Char('j') | KeyCode::Char('J') => {
                    self.scroll_split_down();
                    return Ok(false);
                }
                _ => {}
            }
        }

        // Alt+arrows enter / extend vertical-block selection. Alt+C copies it.
        let alt = key.modifiers.contains(KeyModifiers::ALT);
        if alt {
            match key.code {
                KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right => {
                    if let Some(doc) = self.opened.as_mut() {
                        if doc.block_anchor.is_none() {
                            doc.block_anchor = Some(doc.textarea.cursor());
                        }
                        match key.code {
                            KeyCode::Up => doc.textarea.move_cursor(CursorMove::Up),
                            KeyCode::Down => doc.textarea.move_cursor(CursorMove::Down),
                            KeyCode::Left => doc.textarea.move_cursor(CursorMove::Back),
                            KeyCode::Right => doc.textarea.move_cursor(CursorMove::Forward),
                            _ => {}
                        }
                    }
                    return Ok(false);
                }
                KeyCode::Char('c') | KeyCode::Char('C') => {
                    self.block_copy();
                    return Ok(false);
                }
                _ => {}
            }
        }

        if matches!(key.code, KeyCode::Esc) {
            if let Some(doc) = self.opened.as_mut() {
                if doc.block_anchor.is_some() {
                    doc.block_anchor = None;
                    return Ok(false);
                }
            }
            self.change_focus(Focus::Tree);
            return Ok(false);
        }

        // Any key other than Alt+arrows clears block selection state.
        if let Some(doc) = self.opened.as_mut() {
            if doc.block_anchor.is_some() && !alt {
                doc.block_anchor = None;
            }
        }

        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
        let shift = key.modifiers.contains(KeyModifiers::SHIFT);

        // Plain arrow keys + Home/End/PageUp/PageDown. `input_without_shortcuts`
        // (which we use to silence tui-textarea's emacs defaults) also treats
        // these as shortcuts and drops them — so we route them ourselves.
        // Shift extends a linear selection; plain cancels any selection.
        if !alt && !ctrl {
            let cmove = match key.code {
                KeyCode::Up => Some(CursorMove::Up),
                KeyCode::Down => Some(CursorMove::Down),
                KeyCode::Left => Some(CursorMove::Back),
                KeyCode::Right => Some(CursorMove::Forward),
                KeyCode::Home => Some(CursorMove::Head),
                KeyCode::End => Some(CursorMove::End),
                KeyCode::PageUp => Some(CursorMove::ParagraphBack),
                KeyCode::PageDown => Some(CursorMove::ParagraphForward),
                _ => None,
            };
            if let Some(cmove) = cmove {
                if let Some(doc) = self.opened.as_mut() {
                    if shift {
                        if doc.textarea.selection_range().is_none() {
                            doc.textarea.start_selection();
                        }
                    } else {
                        doc.textarea.cancel_selection();
                    }
                    doc.textarea.move_cursor(cmove);
                }
                return Ok(false);
            }
        }

        // Editor key map (intercepted before tui-textarea so its emacs
        // defaults don't fire). Note the rebinds from earlier conventions:
        //   Ctrl+U undo  (was Ctrl+Z)
        //   Ctrl+K cut   (was Ctrl+X — now "repeat" for search/replace)
        //   Ctrl+P paste (was Ctrl+V)
        //   Ctrl+D/E/W/Z delete-line / delete-to-end / delete-to-start /
        //                delete-to-end (Z duplicates E per spec)
        if ctrl {
            match key.code {
                // Undo / Redo
                KeyCode::Char('u') | KeyCode::Char('U') if !shift => {
                    if let Some(doc) = self.opened.as_mut() {
                        doc.textarea.undo();
                    }
                    return Ok(false);
                }
                KeyCode::Char('y') | KeyCode::Char('Y') if !shift => {
                    if let Some(doc) = self.opened.as_mut() {
                        doc.textarea.redo();
                    }
                    return Ok(false);
                }
                // Clipboard
                KeyCode::Char('k') | KeyCode::Char('K') if !shift => {
                    self.editor_cut();
                    return Ok(false);
                }
                KeyCode::Char('c') | KeyCode::Char('C') if !shift => {
                    self.editor_copy();
                    return Ok(false);
                }
                KeyCode::Char('p') | KeyCode::Char('P') if !shift => {
                    self.editor_paste();
                    return Ok(false);
                }
                KeyCode::Char('a') | KeyCode::Char('A') if !shift => {
                    self.editor_select_all();
                    return Ok(false);
                }
                // Line-targeted deletes. All four operations preserve the
                // yank buffer so they don't clobber Ctrl+C / Ctrl+P state.
                KeyCode::Char('d') | KeyCode::Char('D') if !shift => {
                    self.editor_delete_line();
                    return Ok(false);
                }
                KeyCode::Char('e') | KeyCode::Char('E') if !shift => {
                    self.editor_delete_to_eol();
                    return Ok(false);
                }
                KeyCode::Char('w') | KeyCode::Char('W') if !shift => {
                    self.editor_delete_to_bol();
                    return Ok(false);
                }
                // Ctrl+Z is intentionally unbound. Undo is Ctrl+U,
                // delete-to-EOL is Ctrl+E. The key falls through to
                // input_without_shortcuts (which itself ignores it).
                KeyCode::Home => {
                    if let Some(doc) = self.opened.as_mut() {
                        doc.textarea.move_cursor(CursorMove::Top);
                    }
                    return Ok(false);
                }
                KeyCode::End => {
                    if let Some(doc) = self.opened.as_mut() {
                        doc.textarea.move_cursor(CursorMove::Bottom);
                    }
                    return Ok(false);
                }
                KeyCode::Left => {
                    if let Some(doc) = self.opened.as_mut() {
                        doc.textarea.move_cursor(CursorMove::WordBack);
                    }
                    return Ok(false);
                }
                KeyCode::Right => {
                    if let Some(doc) = self.opened.as_mut() {
                        doc.textarea.move_cursor(CursorMove::WordForward);
                    }
                    return Ok(false);
                }
                KeyCode::Backspace => {
                    if let Some(doc) = self.opened.as_mut() {
                        if doc.textarea.delete_word() {
                            doc.dirty = true;
                        }
                    }
                    return Ok(false);
                }
                _ => {}
            }
        }

        // Auto-close pairs (configurable). Only plain keystrokes — Ctrl /
        // Alt / Super combinations fall through to the textarea catch-all
        // below. Each helper returns `true` when it consumed the key,
        // letting us skip the catch-all without disturbing the cursor.
        let plain_no_mods = !key
            .modifiers
            .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER);
        if plain_no_mods && self.cfg.editor.auto_close_pairs {
            if let KeyCode::Char(c) = key.code {
                if let Some(close) = open_pair_for(c) {
                    self.editor_auto_open_pair(c, close);
                    return Ok(false);
                }
                if is_close_pair_char(c) && self.editor_try_skip_close(c) {
                    return Ok(false);
                }
            }
            if matches!(key.code, KeyCode::Enter) && self.editor_try_expand_pair_on_enter() {
                return Ok(false);
            }
            if matches!(key.code, KeyCode::Backspace) && self.editor_try_delete_pair() {
                return Ok(false);
            }
        }

        // Everything else: pass to textarea WITHOUT its emacs-style defaults,
        // so plain typing/arrows/Home/End/PageUp/PageDown/Shift+arrows still
        // work but Ctrl+letter combinations don't get hijacked.
        if let Some(doc) = self.opened.as_mut() {
            let input: tui_textarea::Input = key.into();
            if doc.textarea.input_without_shortcuts(input) {
                doc.dirty = true;
            }
        }
        // 1.2.14+ Phase Q.2 — snippet expansion.
        // Fires AFTER the textarea has processed the
        // keystroke (the just-typed char is already
        // in the buffer + the cursor is past it).
        // The handler short-circuits when snippets
        // are disabled, no triggers are configured,
        // the key wasn't a trigger character, or no
        // configured trigger matches the prefix.
        if let crossterm::event::KeyCode::Char(c) = key.code {
            if super::snippets::is_expansion_trigger_char(c) {
                self.maybe_expand_snippet();
            }
        } else if matches!(
            key.code,
            crossterm::event::KeyCode::Enter | crossterm::event::KeyCode::Tab
        ) {
            self.maybe_expand_snippet();
        }
        Ok(false)
    }

    /// Copy the current rectangular selection to the system clipboard
    /// (falling back to tui-textarea's yank buffer). The rectangle is the
    /// inclusive range from `block_anchor` to the current cursor.
    fn block_copy(&mut self) {
        let Some(doc) = self.opened.as_ref() else {
            return;
        };
        let Some(anchor) = doc.block_anchor else {
            return;
        };
        let block = BlockSelection::from_anchor_and_cursor(anchor, doc.textarea.cursor());
        let lines = doc.textarea.lines();
        let mut out = String::new();
        for r in block.row_min..=block.row_max {
            if let Some(line) = lines.get(r) {
                let chars: Vec<char> = line.chars().collect();
                let s = block.col_min.min(chars.len());
                let e = (block.col_max + 1).min(chars.len());
                let piece: String = chars[s..e].iter().collect();
                out.push_str(&piece);
            }
            if r < block.row_max {
                out.push('\n');
            }
        }
        // Push to system clipboard if available.
        if let Some(cb) = self.clipboard.as_mut() {
            let _ = cb.set_text(out.clone());
        }
        if let Some(doc) = self.opened.as_mut() {
            doc.textarea.set_yank_text(out);
            doc.block_anchor = None;
        }
        self.status = "copied rectangular block to clipboard".into();
    }

    fn current_block(&self) -> Option<BlockSelection> {
        let doc = self.opened.as_ref()?;
        let anchor = doc.block_anchor?;
        Some(BlockSelection::from_anchor_and_cursor(
            anchor,
            doc.textarea.cursor(),
        ))
    }

    fn handle_passive_key(&mut self, key: KeyEvent) -> Result<bool> {
        // Esc bounces AI pane → AI prompt so the user can edit / send the
        // next message without an extra Tab. Mirror of the AiPrompt → Ai
        // bounce in handle_input_key.
        if self.focus == Focus::Ai && matches!(key.code, KeyCode::Esc) {
            self.change_focus(Focus::AiPrompt);
            return Ok(false);
        }
        // When the AI pane has a completed inference and is focused, single-
        // letter keys apply the result to the editor.
        if self.focus == Focus::Ai && self.inference_done_with_text() {
            match key.code {
                KeyCode::Char('r') | KeyCode::Char('R') => {
                    self.apply_inference(InferenceAction::Replace);
                    return Ok(false);
                }
                KeyCode::Char('i') | KeyCode::Char('I') => {
                    self.apply_inference(InferenceAction::Insert);
                    return Ok(false);
                }
                // `t` / `T` — prepend the AI response to the top of the
                // paragraph (markdown→Typst conversion applied).
                KeyCode::Char('t') | KeyCode::Char('T') => {
                    self.apply_inference(InferenceAction::Top);
                    return Ok(false);
                }
                // `g` / `G` — grammar-check apply: lift only the
                // corrected paragraph (between `<<<CORRECTED>>>` /
                // `<<<END>>>` markers, or last fenced code, or after a
                // "Corrected …" heading) and overwrite the buffer
                // wholesale. No markdown→Typst conversion runs because
                // the grammar prompt instructs the model to preserve
                // Typst markup verbatim.
                KeyCode::Char('g') | KeyCode::Char('G') => {
                    self.apply_inference(InferenceAction::ReplaceCorrected);
                    return Ok(false);
                }
                KeyCode::Char('b') | KeyCode::Char('B') => {
                    self.apply_inference(InferenceAction::Bottom);
                    return Ok(false);
                }
                KeyCode::Char('c') | KeyCode::Char('C') => {
                    self.apply_inference(InferenceAction::CopyOnly);
                    return Ok(false);
                }
                // `l` / `L` — lift a generator response into its system book
                // (submission draft → Submissions, structural analysis →
                // Planning). Only when the finished response actually carries
                // a destination, so plain chat replies leave `l` untouched.
                KeyCode::Char('l') | KeyCode::Char('L')
                    if self.lift_target_matches_current() =>
                {
                    self.lift_inference_to_book();
                    return Ok(false);
                }
                _ => {}
            }
        }

        if matches!(key.code, KeyCode::Char('q') | KeyCode::Char('Q')) {
            return Ok(self.request_quit());
        }
        Ok(false)
    }

    /// Centralized exit gate. If the open paragraph is dirty, autosave it
    /// before returning true. If the save fails, abort the exit and leave a
    /// status message so the user can recover. Called from every quit chord
    /// (Ctrl+Q, plain q in Tree / Editor-empty / AI).
    fn request_quit(&mut self) -> bool {
        if self.opened.as_ref().is_some_and(|d| d.dirty) {
            // save_current writes its own status. If it can't save, doc.dirty
            // stays true and we refuse to quit so the user can see the error
            // and recover.
            let _ = self.save_current();
            if self.opened.as_ref().is_some_and(|d| d.dirty) {
                return false;
            }
        }
        // 1.2.20+ Phase G — warn before quitting with
        // uncommitted git changes.  The open buffer is
        // already saved above, so the working tree reflects
        // the latest edits.  Best-effort: `uncommitted_count`
        // returns None (→ no warning) when the project isn't
        // a git repo or git is unavailable.  Skipped when a
        // modal is already open (Ctrl+Q in-modal stays the
        // unconditional escape hatch).
        if self.cfg.editor.warn_uncommitted_on_exit
            && matches!(self.modal, Modal::None)
        {
            if let Some(n) = crate::git::uncommitted_count(&self.layout.root) {
                if n > 0 {
                    self.modal = Modal::ConfirmQuitUncommitted { count: n };
                    self.status = format!(
                        "{n} uncommitted git change(s) — quit anyway? Y/Enter · N/Esc"
                    );
                    return false;
                }
            }
        }
        // Persist session state regardless of save outcome path above.
        // Failure is silent — sessions are a UX nicety, not correctness.
        let _ = self.save_session();
        true
    }

    /// Re-apply a saved session on startup. Silently ignores anything that
    /// no longer makes sense (missing UUIDs, corrupt fields). Should be
    /// called after `Hierarchy::load` so the lookups can resolve.
    fn restore_session(&mut self) {
        let Some(state) = SessionState::load(&self.layout.root) else {
            return;
        };

        // Per-paragraph cursor map. We restore this BEFORE opening the
        // last-active paragraph so `load_paragraph` finds an entry and seeds
        // the cursor immediately.
        for (key, pc) in &state.paragraph_cursors {
            if let Ok(id) = Uuid::parse_str(key) {
                self.paragraph_cursors.insert(id, *pc);
            }
        }

        // 1.2.7+ — visited-paragraph history. Restore only the
        // entries whose nodes still exist (deleted paragraphs
        // drop out silently), and clamp the cursor.
        let history: Vec<Uuid> = state
            .visited_history
            .iter()
            .filter_map(|s| Uuid::parse_str(s).ok())
            .filter(|id| self.hierarchy.get(*id).is_some())
            .collect();
        if !history.is_empty() {
            let max_idx = history.len().saturating_sub(1);
            self.visited_cursor = state.visited_cursor.min(max_idx);
            self.visited_history = history;
        }
        // 1.2.7+ — per-book timeline view snapshots.
        for (key, snap) in &state.timeline_views {
            if let Ok(id) = Uuid::parse_str(key) {
                if self.hierarchy.get(id).is_some() {
                    self.timeline_views.insert(id, snap.clone());
                }
            }
        }

        // Collapsed branches.
        for s in &state.tree.collapsed_nodes {
            if let Ok(id) = Uuid::parse_str(s) {
                if self.hierarchy.get(id).is_some() {
                    self.collapsed_nodes.insert(id);
                }
            }
        }
        self.rebuild_rows_preserving_cursor();

        // Tree cursor.
        if let Some(cid) = &state.tree.cursor_id {
            if let Ok(id) = Uuid::parse_str(cid) {
                if let Some(i) = self.rows.iter().position(|(rid, _)| *rid == id) {
                    self.tree_cursor = i;
                }
            }
        }

        // Open paragraph + cursor. The per-paragraph map (above) takes
        // precedence over the legacy single-cursor field — load_paragraph
        // restores from the map automatically.
        if let Some(ed) = &state.editor {
            if let Ok(id) = Uuid::parse_str(&ed.opened_id) {
                if let Some(node) = self.hierarchy.get(id).cloned() {
                    if node.kind == NodeKind::Paragraph {
                        let _ = self.load_paragraph(&node);
                        // If a fresh load didn't find a per-paragraph entry
                        // (older session file), fall back to the legacy
                        // single-cursor coordinates.
                        if !self.paragraph_cursors.contains_key(&id) {
                            if let Some(doc) = self.opened.as_mut() {
                                doc.textarea.move_cursor(CursorMove::Jump(
                                    ed.cursor_row as u16,
                                    ed.cursor_col as u16,
                                ));
                            }
                        }
                    }
                }
            }
        }

        // Focus.
        let restored_focus = match state.focus.as_str() {
            "Tree" => Some(Focus::Tree),
            "Editor" => Some(Focus::Editor),
            "Ai" => Some(Focus::Ai),
            "SearchBar" => Some(Focus::SearchBar),
            "AiPrompt" => Some(Focus::AiPrompt),
            _ => None,
        };
        if let Some(f) = restored_focus {
            self.focus = f;
        }
    }

    fn handle_input_key(&mut self, key: KeyEvent, is_search: bool) -> Result<bool> {
        match key.code {
            KeyCode::Esc => {
                if is_search && self.show_results_overlay {
                    self.show_results_overlay = false;
                } else if !is_search && self.show_prompt_picker {
                    self.show_prompt_picker = false;
                } else {
                    self.show_results_overlay = false;
                    self.show_prompt_picker = false;
                    // Search bar → Editor closes the
                    // Editor → Tree → Search → Editor rotation. AI prompt
                    // bounces to AI pane (separate Ai↔AiPrompt pairing).
                    let target = if is_search {
                        Focus::Editor
                    } else {
                        Focus::Ai
                    };
                    self.change_focus(target);
                }
            }
            KeyCode::Enter => {
                if is_search {
                    if self.show_results_overlay && !self.results.is_empty() {
                        let id = self.results[self.results_cursor].id;
                        self.open_search_result(id);
                    } else {
                        self.run_search();
                    }
                } else if self.show_prompt_picker {
                    self.commit_prompt_pick();
                } else {
                    self.start_inference();
                }
            }
            KeyCode::Up if is_search && self.show_results_overlay => {
                if !self.results.is_empty() && self.results_cursor > 0 {
                    self.results_cursor -= 1;
                }
            }
            KeyCode::Down if is_search && self.show_results_overlay => {
                if !self.results.is_empty()
                    && self.results_cursor + 1 < self.results.len()
                {
                    self.results_cursor += 1;
                }
            }
            KeyCode::Up if !is_search && self.show_prompt_picker => {
                if self.prompt_picker_cursor > 0 {
                    self.prompt_picker_cursor -= 1;
                }
            }
            KeyCode::Down if !is_search && self.show_prompt_picker => {
                let n = self.prompt_picker_matches().len();
                if n > 0 && self.prompt_picker_cursor + 1 < n {
                    self.prompt_picker_cursor += 1;
                }
            }
            // 1.2.4+: Up / Down in the AI prompt (no picker
            // showing) walks `ai_prompt_history`. Shell-style.
            KeyCode::Up if !is_search => {
                if !self.ai_prompt_history.is_empty() {
                    let next = match self.ai_prompt_history_cursor {
                        Some(0) => 0,
                        Some(i) => i - 1,
                        None => self.ai_prompt_history.len() - 1,
                    };
                    self.ai_prompt_history_cursor = Some(next);
                    let entry = self.ai_prompt_history[next].clone();
                    self.ai_input.clear();
                    for c in entry.chars() {
                        self.ai_input.insert_char(c);
                    }
                }
            }
            KeyCode::Down if !is_search => {
                if let Some(cur) = self.ai_prompt_history_cursor {
                    let next = cur + 1;
                    if next >= self.ai_prompt_history.len() {
                        // Past the newest entry → leave history
                        // navigation, clear the input.
                        self.ai_prompt_history_cursor = None;
                        self.ai_input.clear();
                    } else {
                        self.ai_prompt_history_cursor = Some(next);
                        let entry = self.ai_prompt_history[next].clone();
                        self.ai_input.clear();
                        for c in entry.chars() {
                            self.ai_input.insert_char(c);
                        }
                    }
                }
            }
            KeyCode::Tab if !is_search && self.show_prompt_picker => {
                self.commit_prompt_pick();
            }
            KeyCode::Backspace => {
                self.current_input(is_search).backspace();
                if is_search {
                    self.show_results_overlay = false;
                }
                if !is_search {
                    self.ai_prompt_history_cursor = None;
                }
            }
            KeyCode::Delete => {
                self.current_input(is_search).delete();
                if is_search {
                    self.show_results_overlay = false;
                }
                if !is_search {
                    self.ai_prompt_history_cursor = None;
                }
            }
            KeyCode::Left => self.current_input(is_search).move_left(),
            KeyCode::Right => self.current_input(is_search).move_right(),
            KeyCode::Home => self.current_input(is_search).move_home(),
            KeyCode::End => self.current_input(is_search).move_end(),
            KeyCode::Char(c) => {
                // Explicit AI-prompt focus shortcuts. The global Ctrl+1..5
                // block at the top of `handle_key` covers these too, but
                // some terminals re-encode the chords in ways that bypass
                // the global path — handle them locally as a safety net so
                // they always work from the AI prompt.
                if !is_search
                    && key.modifiers.contains(KeyModifiers::CONTROL)
                    && !key
                        .modifiers
                        .intersects(KeyModifiers::ALT | KeyModifiers::SUPER)
                {
                    if c == '1' {
                        self.change_focus(Focus::Editor);
                        return Ok(false);
                    }
                    if c == 't' || c == 'T' {
                        self.change_focus(Focus::Tree);
                        return Ok(false);
                    }
                }
                let mut residual = key.modifiers;
                residual.remove(KeyModifiers::SHIFT);
                if residual.is_empty() {
                    let final_c = if key.modifiers.contains(KeyModifiers::SHIFT)
                        && c.is_ascii_alphabetic()
                    {
                        c.to_ascii_uppercase()
                    } else {
                        c
                    };
                    self.current_input(is_search).insert_char(final_c);
                    if is_search {
                        self.show_results_overlay = false;
                    } else {
                        // 1.2.4+: typing in the AI prompt
                        // breaks history-recall navigation —
                        // the next Up arrow starts at the
                        // newest entry again.
                        self.ai_prompt_history_cursor = None;
                        self.refresh_prompt_picker();
                    }
                }
            }
            _ => {}
        }
        // After Backspace/Delete in the AI input, also refresh the picker.
        if !is_search {
            self.refresh_prompt_picker();
        }
        Ok(false)
    }

    fn refresh_prompt_picker(&mut self) {
        let s = self.ai_input.as_str();
        self.show_prompt_picker = s.starts_with('/');
        self.prompt_picker_cursor = self
            .prompt_picker_cursor
            .min(self.prompt_picker_matches().len().saturating_sub(1));
    }

    fn commit_prompt_pick(&mut self) {
        let matches = self.prompt_picker_matches();
        let Some(picked) = matches.into_iter().nth(self.prompt_picker_cursor) else {
            self.status = "no matching prompt".into();
            return;
        };
        let template = match &picked.body {
            PromptBody::Static(t) => t.clone(),
            PromptBody::BookParagraph(id) => {
                match self.store.get_content(*id) {
                    Ok(Some(bytes)) => {
                        let text = String::from_utf8_lossy(&bytes).to_string();
                        // Strip the leading `= Title` Typst heading that the
                        // editor inserts by default so it doesn't pollute the
                        // prompt sent to the LLM.
                        strip_leading_typst_heading(&text)
                    }
                    Ok(None) => {
                        self.status = format!("prompt `{}` has no body", picked.name);
                        return;
                    }
                    Err(e) => {
                        self.status = format!(
                            "loading prompt `{}` from book failed: {e}",
                            picked.name
                        );
                        return;
                    }
                }
            }
        };
        let body = self.render_template(&template);
        self.ai_input.clear();
        for c in body.chars() {
            self.ai_input.insert_char(c);
        }
        self.show_prompt_picker = false;
        let chip = match picked.source {
            PromptSource::System => "system",
            PromptSource::Book => "book",
        };
        self.status = format!(
            "loaded prompt `{}` [{chip}] — Enter to send",
            picked.name
        );
    }

    /// Look up a prompt by name inside the Prompts system book. Returns the
    /// paragraph body with the leading `= Title` heading stripped, ready to
    /// be passed through `render_template`. Returns None if no such
    /// paragraph exists or its body can't be loaded.
    /// 1.2.6+ — unified prompt resolver used by F7 / F11 / F12.
    /// Precedence (highest first):
    ///
    ///   1. Paragraph in the Prompts system book whose slug or
    ///      title matches `name` (case-insensitive) — wins
    ///      regardless of any `.example` siblings.
    ///   2. Same-named paragraph with the literal display
    ///      title (`name.replace('-', ' ')`).
    ///   3. Entry in `prompts.hjson` named `name`.
    ///   4. The supplied embedded fallback.
    ///
    /// `inkhaven init` seeds `<name>.example` paragraphs into
    /// the Prompts book for each embedded default so the user
    /// can review/tune and remove the `.example` suffix to
    /// take effect.
    fn resolve_prompt_template(
        &self,
        name: &str,
        fallback: impl FnOnce() -> String,
    ) -> String {
        // 1.2.12+ — Phase A.  The legacy single-language
        // resolver now delegates to the three-pass language-
        // aware resolver, with `active_prompt_language()` as
        // the target.  Pass 2 (untagged) is what keeps every
        // pre-1.2.12 project working: every existing prompt
        // is untagged, so it lands in Pass 2 with the same
        // semantics as the old code.  See
        // `Documentation/PROPOSALS/MULTILINGUAL_PROMPTS.md`.
        let want_lang = self.active_prompt_language();
        self.resolve_prompt(name, &want_lang, fallback).template
    }

    /// 1.2.12+ — three-pass language-aware prompt resolver
    /// with an embedded-fallback floor.  Pass 1 looks for an
    /// exact `want_lang` match; Pass 2 looks for an untagged
    /// match (back-compat for projects that pre-date the
    /// language attribute); Pass 3 accepts any-language
    /// matches with the supplied embedded fallback (English
    /// by current convention) as the floor.  Within each
    /// pass, both `name` and its space-substituted form
    /// (`grammar-check` → `grammar check`) are tried so
    /// prompt-book paragraphs named with either convention
    /// resolve.  Returns the resolved template along with
    /// the language and source the resolver landed on —
    /// Phase C will surface the `found_lang` / `source` in
    /// the AI pane title bar.
    pub(super) fn resolve_prompt(
        &self,
        name: &str,
        want_lang: &str,
        fallback: impl FnOnce() -> String,
    ) -> ResolvedPrompt {
        if let Some(found) = self.resolve_prompt_optional(name, want_lang) {
            return found;
        }
        // Floor — caller's embedded fallback.  Until Phase B
        // ships the per-language embedded tables, the
        // fallback is always English.
        ResolvedPrompt::new(fallback(), "en", PromptResolveSource::Embedded)
    }

    /// 1.2.12+ — same three-pass search as `resolve_prompt`
    /// but WITHOUT the embedded fallback.  Returns `None`
    /// when nothing matches.  Used by the `/name [args]`
    /// AI-prompt form where the user typed an explicit
    /// name and there's no embedded prompt to fall back to
    /// for arbitrary names — the caller surfaces the
    /// not-found case as a status message.
    pub(super) fn resolve_prompt_optional(
        &self,
        name: &str,
        want_lang: &str,
    ) -> Option<ResolvedPrompt> {
        let display = name.replace('-', " ");
        let want_lang_lc = want_lang.to_lowercase();

        // ── Pass 1 — strict same-language ──
        if let Some(t) =
            self.lookup_book_prompt_template_with_lang(name, Some(&want_lang_lc))
        {
            return Some(ResolvedPrompt::new(t, &want_lang_lc, PromptResolveSource::Book));
        }
        if let Some(t) = self
            .lookup_book_prompt_template_with_lang(&display, Some(&want_lang_lc))
        {
            return Some(ResolvedPrompt::new(t, &want_lang_lc, PromptResolveSource::Book));
        }
        if let Some(p) = self.prompts.find_lang(name, Some(&want_lang_lc)) {
            return Some(ResolvedPrompt::new(
                p.template.clone(),
                &want_lang_lc,
                PromptResolveSource::Hjson,
            ));
        }
        if let Some(p) = self.prompts.find_lang(&display, Some(&want_lang_lc)) {
            return Some(ResolvedPrompt::new(
                p.template.clone(),
                &want_lang_lc,
                PromptResolveSource::Hjson,
            ));
        }

        // ── Pass 2 — untagged (back-compat) ──
        if let Some(t) = self.lookup_book_prompt_template_with_lang(name, None) {
            return Some(ResolvedPrompt::new(t, "untagged", PromptResolveSource::Book));
        }
        if let Some(t) = self.lookup_book_prompt_template_with_lang(&display, None)
        {
            return Some(ResolvedPrompt::new(t, "untagged", PromptResolveSource::Book));
        }
        if let Some(p) = self.prompts.find_lang(name, None) {
            return Some(ResolvedPrompt::new(
                p.template.clone(),
                "untagged",
                PromptResolveSource::Hjson,
            ));
        }
        if let Some(p) = self.prompts.find_lang(&display, None) {
            return Some(ResolvedPrompt::new(
                p.template.clone(),
                "untagged",
                PromptResolveSource::Hjson,
            ));
        }

        // ── Pass 3 — any-language ──
        if let Some((t, lang)) = self.lookup_book_prompt_any_language(name) {
            return Some(ResolvedPrompt::new(t, &lang, PromptResolveSource::Book));
        }
        if let Some((t, lang)) = self.lookup_book_prompt_any_language(&display) {
            return Some(ResolvedPrompt::new(t, &lang, PromptResolveSource::Book));
        }
        if let Some(p) = self.prompts.find(name) {
            let lang = p.language.clone().unwrap_or_else(|| "untagged".into());
            return Some(ResolvedPrompt::new(
                p.template.clone(),
                &lang,
                PromptResolveSource::Hjson,
            ));
        }
        if let Some(p) = self.prompts.find(&display) {
            let lang = p.language.clone().unwrap_or_else(|| "untagged".into());
            return Some(ResolvedPrompt::new(
                p.template.clone(),
                &lang,
                PromptResolveSource::Hjson,
            ));
        }

        None
    }

    /// 1.2.12+ — Prompts-book paragraph lookup, optionally
    /// filtered by the `lang:<code>` tag convention.
    ///
    ///   * `lang_filter = Some("ru")` — only paragraphs whose
    ///     `Node.tags` contain `lang:ru` are considered.
    ///   * `lang_filter = None`       — only paragraphs with
    ///     NO `lang:*` tag are considered (Pass 2).
    fn lookup_book_prompt_template_with_lang(
        &self,
        name: &str,
        lang_filter: Option<&str>,
    ) -> Option<String> {
        let book_id = self.system_book_id(crate::store::SYSTEM_TAG_PROMPTS)?;
        let lower = name.to_lowercase();
        for id in self.hierarchy.collect_subtree(book_id) {
            if id == book_id {
                continue;
            }
            let node = self.hierarchy.get(id)?;
            if node.kind != NodeKind::Paragraph {
                continue;
            }
            if node.slug.to_lowercase() != lower
                && node.title.to_lowercase() != lower
            {
                continue;
            }
            if !node_lang_matches(node, lang_filter) {
                continue;
            }
            let bytes = self.store.get_content(node.id).ok().flatten()?;
            let text = String::from_utf8_lossy(&bytes).to_string();
            return Some(strip_leading_typst_heading(&text));
        }
        None
    }

    /// 1.2.12+ — Pass 3 helper: return the first
    /// matching-name Prompts-book paragraph regardless of
    /// language tag, plus the language code it was tagged
    /// with (or `"untagged"` when none).
    fn lookup_book_prompt_any_language(
        &self,
        name: &str,
    ) -> Option<(String, String)> {
        let book_id = self.system_book_id(crate::store::SYSTEM_TAG_PROMPTS)?;
        let lower = name.to_lowercase();
        for id in self.hierarchy.collect_subtree(book_id) {
            if id == book_id {
                continue;
            }
            let node = self.hierarchy.get(id)?;
            if node.kind != NodeKind::Paragraph {
                continue;
            }
            if node.slug.to_lowercase() != lower
                && node.title.to_lowercase() != lower
            {
                continue;
            }
            let lang = node_lang_tag(node).unwrap_or_else(|| "untagged".into());
            let bytes = self.store.get_content(node.id).ok().flatten()?;
            let text = String::from_utf8_lossy(&bytes).to_string();
            return Some((strip_leading_typst_heading(&text), lang));
        }
        None
    }

    /// 1.2.12+ — the language to resolve prompts against on
    /// this call.  Reads (in order of precedence):
    ///   1. Session-local runtime override
    ///      (`Ctrl+B Shift+N`, Phase C).
    ///   2. `editor.prompt_language_mode` (HJSON;
    ///      `book_defined` default).
    ///
    /// When the resolved mode is `paragraph_detected`,
    /// returns the cached whatlang code for the open
    /// paragraph if available — falling back to the book
    /// language when the paragraph is too short to detect
    /// reliably (see `detect_paragraph_language`).
    pub(super) fn active_prompt_language(&self) -> String {
        let mode = self
            .prompt_lang_mode_runtime
            .as_deref()
            .unwrap_or(self.cfg.editor.prompt_language_mode.as_str());
        if mode.eq_ignore_ascii_case("book_defined") {
            return crate::ai::prompts::iso_from_long(&self.cfg.language).to_string();
        }
        // paragraph_detected (or any non-book-defined value)
        if let Some(doc) = self.opened.as_ref() {
            if let Some(code) = doc.detected_language.as_deref() {
                return code.to_string();
            }
        }
        // Detection failed or paragraph too short — fall back
        // to book language silently.
        crate::ai::prompts::iso_from_long(&self.cfg.language).to_string()
    }

    /// 1.2.12+ Phase C — effective prompt-language mode
    /// for display purposes (AI pane title bar, status
    /// echo on toggle).  Returns the runtime override if
    /// set, otherwise the HJSON value.
    pub(super) fn effective_prompt_language_mode(&self) -> &str {
        self.prompt_lang_mode_runtime
            .as_deref()
            .unwrap_or(self.cfg.editor.prompt_language_mode.as_str())
    }

    /// 1.2.12+ Phase C — Ctrl+B Shift+N handler.  Cycles
    /// the runtime override through:
    ///   None (defer to HJSON)
    ///     → Some("book_defined")
    ///     → Some("paragraph_detected")
    ///     → None
    /// rather than a binary flip so the user can return to
    /// the HJSON default without restarting.  Echoes the
    /// effective mode on the status bar so the chord is
    /// self-documenting.
    pub(super) fn toggle_prompt_language_mode(&mut self) {
        let next = match self.prompt_lang_mode_runtime.as_deref() {
            None => Some("book_defined".to_string()),
            Some(m) if m.eq_ignore_ascii_case("book_defined") => {
                Some("paragraph_detected".to_string())
            }
            Some(m) if m.eq_ignore_ascii_case("paragraph_detected") => None,
            _ => Some("book_defined".to_string()),
        };
        self.prompt_lang_mode_runtime = next;
        // Re-run detection so the cache is fresh against
        // the new mode.  No-op when the effective mode
        // ends up `book_defined` (detect_paragraph_language
        // short-circuits there).
        self.detect_paragraph_language();
        let effective = self.effective_prompt_language_mode().to_string();
        let lang = self.active_prompt_language();
        let suffix = match self.prompt_lang_mode_runtime.as_deref() {
            None => format!("HJSON default ({effective})"),
            Some(_) => "session override".into(),
        };
        self.status = format!(
            "prompt language mode: {effective} · resolving as `{lang}` · {suffix}",
        );
    }

    /// 1.2.12+ Phase A — toggle the fullscreen
    /// split-view layout flag.  When turning OFF, the
    /// secondary slot is cleared.
    ///
    /// Why clear on exit: the existing Ctrl+V S
    /// similar-mode reuses the same `secondary` slot
    /// AND the standard-layout renderer's rule "if
    /// secondary.is_some(), put secondary in the right
    /// column instead of the AI pane".  If we kept the
    /// pin across split-view exit, the standard
    /// layout would silently lose the AI pane.  The
    /// less-surprising default is: Shift+F4 off →
    /// secondary slot cleared → standard layout with
    /// AI pane back where it belongs.  Re-pinning
    /// before re-entering split-view is one
    /// `Ctrl+V P` / `Shift+Enter` away.
    ///
    /// Phase 0 plan §7 picked "keep pinned" but the
    /// downstream interaction with similar-mode's
    /// display rule made that choice misleading in
    /// practice.  Revised here based on user report.
    pub(super) fn toggle_split_view(&mut self) {
        self.split_view = !self.split_view;
        let on = self.split_view;
        if !on {
            // 1.2.12+ — drop the secondary on exit
            // ONLY when similar-mode isn't currently
            // owning the slot.  When similar-mode is
            // active, Shift+F4 just toggles the
            // display layout and leaves the slot
            // alone so similar-mode's right-column
            // editor reappears in the standard
            // layout.
            //
            // The save-on-clear path preserves dirty
            // edits — same protection similar-mode's
            // exit uses.
            if !self.similar_mode {
                if let Some(mut sec) = self.secondary.take() {
                    if sec.dirty {
                        if let Err(e) = self.save_doc(&mut sec) {
                            tracing::warn!(
                                target: "inkhaven::split_view",
                                "secondary save on exit failed: {e:#}",
                            );
                        }
                    }
                }
                self.secondary_focused = false;
            }
        }
        let has_secondary = self.secondary.is_some();
        self.status = match (on, has_secondary, self.similar_mode) {
            (true, true, _) => {
                "split view: ON · Tab swaps panes · Shift+F4 closes".into()
            }
            (true, false, _) => {
                "split view: ON (right pane empty — open a paragraph picker to fill it) · Shift+F4 closes".into()
            }
            (false, _, true) => {
                "split view: OFF (similar-mode kept; secondary stays pinned)".into()
            }
            (false, _, false) => {
                "split view: OFF (secondary cleared)".into()
            }
        };
    }

    /// 1.2.12+ Phase A — populate the `secondary`
    /// slot from any paragraph UUID.  Mirrors the
    /// shape of `open_paragraph_by_uuid` but writes
    /// to `self.secondary` instead of `self.opened`.
    /// Phase B wires every paragraph picker through
    /// this; Phase A defines the helper so callers
    /// can land in B without a parallel rewrite.
    ///
    /// Refuses to pin the same paragraph that's
    /// already primary — showing the same body in
    /// both panes is never useful and would
    /// surprise the user.
    #[allow(dead_code)]
    pub(super) fn pin_secondary_by_uuid(
        &mut self,
        id: uuid::Uuid,
    ) -> std::result::Result<(), String> {
        if self.opened.as_ref().is_some_and(|d| d.id == id) {
            return Err(
                "split view: that paragraph is already in the primary pane".into(),
            );
        }
        // Reuse the existing similar-mode loader; it
        // already looks up the node + constructs an
        // OpenedDoc with full textarea + cursor-memory
        // + style discipline.  Rejects non-paragraph
        // nodes and missing-from-hierarchy IDs.
        self.load_secondary_paragraph(id)
    }

    /// 1.2.12+ Phase A — clear the secondary slot.
    /// Used by Phase B's picker dispatch when the
    /// user explicitly drops the secondary; also a
    /// natural undo for "I pinned the wrong paragraph".
    #[allow(dead_code)]
    pub(super) fn drop_secondary(&mut self) {
        self.secondary = None;
        self.secondary_focused = false;
    }

    /// 1.2.12+ Phase D — sibling-book lookup for the
    /// split-view secondary pane.  Given the open
    /// paragraph's slug, walks the project's hierarchy
    /// for paragraphs with the same slug under a
    /// *different* top-level book (i.e., a different
    /// "book ancestor" — the topmost Book node in the
    /// path).  Results:
    ///
    ///   * Zero matches → status message names the
    ///     slug we tried.
    ///   * One match → auto-pin to `secondary` via
    ///     `dispatch_picker_accept(id, true)`.  The
    ///     user typically follows up with `Shift+F4`
    ///     to view the split.
    ///   * Two or more matches → open a fuzzy
    ///     paragraph picker scoped to the matches
    ///     (the same modal as `Ctrl+V P`, so
    ///     `Shift+Enter` still pins).
    pub(super) fn sibling_book_lookup(&mut self) {
        let Some(doc) = self.opened.as_ref() else {
            self.status = "sibling lookup: no paragraph open".into();
            return;
        };
        let primary_id = doc.id;
        let Some(node) = self.hierarchy.get(primary_id) else {
            self.status = "sibling lookup: open paragraph missing from hierarchy".into();
            return;
        };
        let slug = node.slug.clone();
        let title = node.title.clone();
        // Identify the top-level book ancestor of the
        // open paragraph so we exclude same-book
        // matches from the search.  `slug_path` joins
        // ancestor slugs, the first segment is the
        // book.  Same primitive the concordance Pass 1
        // dispatcher uses.
        let primary_book_slug = self
            .hierarchy
            .ancestors(node)
            .first()
            .map(|n| n.slug.clone())
            .unwrap_or_default();
        // Walk every paragraph node in the project,
        // keeping ones whose slug matches and whose
        // top-level book is NOT the primary's.
        use crate::store::NodeKind;
        let mut matches: Vec<ScriptPickerEntry> = Vec::new();
        for cand in self.hierarchy.iter() {
            if cand.kind != NodeKind::Paragraph || cand.id == primary_id {
                continue;
            }
            if cand.slug != slug {
                continue;
            }
            let cand_book_slug = self
                .hierarchy
                .ancestors(cand)
                .first()
                .map(|n| n.slug.clone())
                .unwrap_or_default();
            if cand_book_slug == primary_book_slug {
                continue;
            }
            let slug_path = self.hierarchy.slug_path(cand);
            matches.push(ScriptPickerEntry {
                id: cand.id,
                title: cand.title.clone(),
                slug_path,
            });
        }
        match matches.len() {
            0 => {
                self.status = format!(
                    "sibling lookup: no paragraph slug `{slug}` in any other book (open: `{title}`)",
                );
            }
            1 => {
                let id = matches[0].id;
                let slug_path = matches[0].slug_path.clone();
                self.dispatch_picker_accept(id, true);
                // Override the default pin status with
                // something that names the sibling
                // path — useful confirmation in
                // multi-book projects.
                self.status = format!(
                    "sibling: pinned `{slug_path}` to secondary — Shift+F4 to view split",
                );
            }
            n => {
                // Multi-match: reuse the fuzzy
                // paragraph picker scoped to just
                // these entries.  Input starts empty
                // because the entries list is already
                // pre-filtered to same-slug matches —
                // typing in the input narrows by
                // fuzzy title / slug_path.
                self.modal = crate::tui::modal::Modal::FuzzyParagraphPicker {
                    input: crate::tui::input::TextInput::new(),
                    entries: matches,
                    cursor: 0,
                    scroll: 0,
                };
                self.status = format!(
                    "sibling lookup: {n} matches for `{slug}` · Enter opens · Shift+Enter pins to split · Esc cancels",
                );
            }
        }
    }

    /// 1.2.12+ Phase B — universal dispatcher for
    /// paragraph-picker accepts.  `pin_to_secondary`
    /// distinguishes plain Enter (route to primary
    /// via `open_search_result`) from Shift+Enter
    /// (route to the `secondary` slot via
    /// `pin_secondary_by_uuid`).  Surfaces pin
    /// failures on the status bar rather than
    /// silently dropping them so the user knows
    /// when e.g. the chosen paragraph is already
    /// primary.
    ///
    /// Callers extract `pin_to_secondary` from the
    /// SHIFT modifier on the Enter keystroke.  Used
    /// by the fuzzy paragraph picker (which also
    /// backs the recent-paragraph picker), the
    /// bookmark picker, and the tree-pane Enter
    /// dispatcher.
    pub(super) fn dispatch_picker_accept(
        &mut self,
        id: uuid::Uuid,
        pin_to_secondary: bool,
    ) {
        if pin_to_secondary {
            match self.pin_secondary_by_uuid(id) {
                Ok(()) => {
                    self.status =
                        "pinned to secondary pane — Shift+F4 to view split".into();
                }
                Err(e) => {
                    self.status = format!("split view: {e}");
                }
            }
        } else {
            self.open_search_result(id);
        }
    }

    /// 1.2.12+ Phase C — short label for the AI pane
    /// title decoration.  Format:
    ///   `<lang> (<mode-shorthand>)`
    /// where the shorthand collapses
    /// `book_defined` → `book` and
    /// `paragraph_detected` → `paragraph`.
    /// Used by the AI pane renderer; kept here next to
    /// `active_prompt_language` so the label stays in
    /// sync with the resolver target.
    pub(super) fn ai_pane_language_label(&self) -> String {
        let lang = self.active_prompt_language();
        let mode = self.effective_prompt_language_mode();
        let shorthand = if mode.eq_ignore_ascii_case("book_defined") {
            "book"
        } else if mode.eq_ignore_ascii_case("paragraph_detected") {
            "paragraph"
        } else {
            "?"
        };
        format!("{lang} ({shorthand})")
    }

    /// 1.2.12+ — run whatlang on the currently-opened
    /// paragraph and cache the result on
    /// `OpenedDoc.detected_language`.  No-op when:
    ///
    ///   * The effective prompt-language mode is
    ///     `book_defined` (book mode never consults
    ///     paragraph detection).  "Effective" means
    ///     the session override
    ///     (`prompt_lang_mode_runtime`) when set,
    ///     otherwise `editor.prompt_language_mode` from
    ///     HJSON — so the Ctrl+B Shift+N flip into
    ///     `paragraph_detected` actually triggers a
    ///     fresh run.
    ///   * No paragraph is open
    ///   * The body has fewer non-whitespace characters than
    ///     `editor.prompt_language_detection_min_chars`
    ///     (whatlang is unreliable on short text)
    ///   * whatlang's detection isn't confident, OR it
    ///     reports a language outside inkhaven's supported
    ///     set (`en/ru/es/de/fr`)
    ///
    /// All of the above leave `detected_language = None`,
    /// which the resolver treats as "fall back to book
    /// language".
    pub(super) fn detect_paragraph_language(&mut self) {
        if self.effective_prompt_language_mode()
            .eq_ignore_ascii_case("book_defined")
        {
            return;
        }
        let min_chars = self.cfg.editor.prompt_language_detection_min_chars;
        let Some(doc) = self.opened.as_mut() else {
            return;
        };
        let body: String = doc.textarea.lines().join("\n");
        let nws_len = body.chars().filter(|c| !c.is_whitespace()).count();
        if nws_len < min_chars {
            doc.detected_language = None;
            doc.detected_language_length = nws_len;
            return;
        }
        let info = whatlang::detect(&body);
        let code = info
            .filter(|i| i.is_reliable())
            .and_then(|i| crate::ai::prompts::iso_from_alpha3(i.lang().code()));
        doc.detected_language = code.map(|s| s.to_string());
        doc.detected_language_length = nws_len;
    }

    /// 1.2.12+ Phase D — cheap edit-time re-detection
    /// guard.  Compares the current non-whitespace
    /// length against the cached
    /// `detected_language_length`; only calls
    /// `detect_paragraph_language` when the delta
    /// exceeds `PARAGRAPH_LANGUAGE_REDETECT_THRESHOLD`.
    /// Cheaper than running whatlang on every save —
    /// typing a sentence here or there doesn't change
    /// the dominant-language signal, but a multi-
    /// paragraph rewrite (or a paste of foreign text)
    /// does.  Threshold is the same as
    /// `prompt_language_detection_min_chars` (50 by
    /// default): a body that crosses 50 chars in
    /// either direction is meaningfully different
    /// from what whatlang last saw.  Called from
    /// `save_current`, `apply_ai_diff_accepted`, and
    /// the AI critique apply paths.
    pub(super) fn maybe_redetect_paragraph_language(&mut self) {
        if self.effective_prompt_language_mode()
            .eq_ignore_ascii_case("book_defined")
        {
            return;
        }
        let threshold = self
            .cfg
            .editor
            .prompt_language_detection_min_chars
            .max(1);
        let needs_redetect = match self.opened.as_ref() {
            Some(doc) => {
                let body: String = doc.textarea.lines().join("\n");
                let nws_len = body.chars().filter(|c| !c.is_whitespace()).count();
                let delta = nws_len.abs_diff(doc.detected_language_length);
                delta >= threshold
            }
            None => false,
        };
        if needs_redetect {
            self.detect_paragraph_language();
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ResolvedPrompt {
    pub template: String,
    /// ISO 639-1 code the resolver actually used, OR the
    /// literal string `"untagged"` when the matched prompt
    /// has no language attached.
    pub found_lang: String,
    pub source: PromptResolveSource,
}

/// 1.3.9 EDITORIAL-3 P3 — an in-flight "fix all of category X" run started
/// from the Editorial Pass cockpit (`F`). Each queued finding is walked
/// through the SAME per-item diff review as a single `f` — never blind-
/// applied — so the user accepts or rejects every rewrite. `Esc` in the diff
/// abandons whatever's left.
#[derive(Debug, Default)]
pub(super) struct EditorialBatch {
    /// Remaining fixes to walk, in order.
    pub queue: std::collections::VecDeque<crate::editorial::BatchFix>,
    /// How many were enqueued (for the `n/total` progress chip).
    pub total: usize,
    pub applied: usize,
    pub skipped: usize,
}

/// 1.3.10 WORLD-2 P3 — the story bible's read-only view of the drift sidecar:
/// per-entity (lowercased name) contradictions + the retrieved description
/// trail. Borrows from a `DriftReport` held for the duration of the rows build.
struct DriftBibleView<'a> {
    conflicts: &'a std::collections::BTreeMap<String, Vec<&'a crate::drift::DriftConflict>>,
    descriptions: &'a std::collections::BTreeMap<String, &'a crate::drift::EntityDescriptions>,
}

/// 1.3.12 DEEP-1 P0 — a message from a background job thread to the UI.
pub(super) enum BgMsg {
    /// A human-readable progress line (replaces the previous one).
    Progress(String),
    /// The job finished — `Ok(summary)` or `Err(reason)`.
    Done(std::result::Result<String, String>),
}

/// Which background job is running — the completion handler dispatches on it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum BgJobKind {
    /// The deep AI refresh (facts check / facts scan / drift / continuity).
    DeepRefresh,
}

/// An in-flight background job: its progress/result channel + a label + which
/// kind it is (for the completion handler). One at a time.
pub(super) struct BgJob {
    pub rx: std::sync::mpsc::Receiver<BgMsg>,
    pub label: String,
    pub kind: BgJobKind,
}

/// Drain everything available on a background job's channel without blocking:
/// the last progress line seen (if any) and the terminal outcome (if the job
/// finished or its sender dropped). Pure over the receiver.
fn drain_bg_messages(
    rx: &std::sync::mpsc::Receiver<BgMsg>,
) -> (Option<String>, Option<std::result::Result<String, String>>) {
    use std::sync::mpsc::TryRecvError;
    let mut last_progress = None;
    loop {
        match rx.try_recv() {
            Ok(BgMsg::Progress(s)) => last_progress = Some(s),
            Ok(BgMsg::Done(r)) => return (last_progress, Some(r)),
            Err(TryRecvError::Empty) => return (last_progress, None),
            Err(TryRecvError::Disconnected) => {
                return (
                    last_progress,
                    Some(Err("background job ended unexpectedly".into())),
                );
            }
        }
    }
}

impl ResolvedPrompt {
    fn new(template: String, found_lang: &str, source: PromptResolveSource) -> Self {
        Self {
            template,
            found_lang: found_lang.to_string(),
            source,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum PromptResolveSource {
    Book,
    Hjson,
    Embedded,
}

/// 1.2.12+ Phase A — which slot does a picker target.
/// Captured at picker-open time (see Phase 0 plan §3) so
/// Tabbing while the picker is up can't reroute the
/// pick.  `Primary` is the default; `Secondary` is set
/// when a picker is opened from inside split-view with
/// the right pane focused.  `#[allow(dead_code)]` on
/// `Secondary` is intentional for Phase A — no caller
/// reads it yet; Phase B wires the pickers through.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(super) enum PaneTarget {
    #[default]
    Primary,
    #[allow(dead_code)]
    Secondary,
}

/// 1.2.12+ — true when the node's `lang:<code>` tag matches
/// `lang_filter`.  `lang_filter = Some("ru")` requires a
/// `lang:ru` tag; `lang_filter = None` requires the absence
/// of any `lang:*` tag (the untagged-back-compat Pass 2).
fn node_lang_matches(
    node: &crate::store::node::Node,
    lang_filter: Option<&str>,
) -> bool {
    let actual = node_lang_tag(node);
    match (lang_filter, actual.as_deref()) {
        (Some(want), Some(have)) => have.eq_ignore_ascii_case(want),
        (None, None) => true,
        _ => false,
    }
}

/// 1.2.12+ — pull the `lang:<code>` tag value out of the
/// node's tag list.  Returns the lowercased code (`ru`,
/// `en`, …) when exactly one such tag is present; `None`
/// when no `lang:*` tag is set.  Multiple `lang:*` tags
/// (a user mistake) — the first wins; the others are
/// silently ignored.
fn node_lang_tag(node: &crate::store::node::Node) -> Option<String> {
    for t in &node.tags {
        let lc = t.to_lowercase();
        if let Some(rest) = lc.strip_prefix("lang:") {
            let code = rest.trim();
            if !code.is_empty() {
                return Some(code.to_string());
            }
        }
    }
    None
}

impl App {

    fn current_selection_or_paragraph(&self) -> String {
        let Some(doc) = self.opened.as_ref() else {
            return String::new();
        };
        if let Some(((r1, c1), (r2, c2))) = doc.textarea.selection_range() {
            let lines = doc.textarea.lines();
            return slice_lines(lines, r1, c1, r2, c2);
        }
        doc.textarea.lines().join("\n")
    }

    fn current_context_breadcrumb(&self) -> String {
        let Some(doc) = self.opened.as_ref() else {
            return String::new();
        };
        let Some(node) = self.hierarchy.get(doc.id) else {
            return String::new();
        };
        let mut parts: Vec<String> = self
            .hierarchy
            .ancestors(node)
            .into_iter()
            .map(|n| n.title.clone())
            .collect();
        parts.push(node.title.clone());
        parts.join(" › ")
    }

    fn run_search(&mut self) {
        let query = self.search_input.as_str().trim().to_string();
        if query.is_empty() {
            self.show_results_overlay = false;
            self.results.clear();
            self.status = "empty query".into();
            return;
        }
        match self.store.search_text(&query, 10) {
            Ok(raw) => {
                self.results = raw.iter().filter_map(SearchHit::parse).collect();
                self.results_cursor = 0;
                self.show_results_overlay = true;
                self.status = format!(
                    "`{}` → {} result(s) · ↑/↓ to navigate · Enter to open · Esc to close",
                    query,
                    self.results.len()
                );
            }
            Err(e) => {
                self.status = format!("search failed: {e}");
            }
        }
    }

    fn open_search_result(&mut self, id: Uuid) {
        if let Some(i) = self.rows.iter().position(|(rid, _)| *rid == id) {
            self.tree_cursor = i;
        }
        let node = match self.hierarchy.get(id).cloned() {
            Some(n) => n,
            None => {
                self.status = format!("hit {id} no longer in hierarchy — try a fresh search");
                self.show_results_overlay = false;
                return;
            }
        };
        self.show_results_overlay = false;
        if matches!(node.kind, NodeKind::Paragraph) {
            if let Err(e) = self.load_paragraph(&node) {
                self.status = format!("open failed: {e}");
            }
        } else {
            self.change_focus(Focus::Tree);
            self.status = format!(
                "`{}` is a {} — its paragraph children are editable",
                node.title,
                node.kind.as_str()
            );
        }
    }

    fn current_input(&mut self, is_search: bool) -> &mut TextInput {
        if is_search {
            &mut self.search_input
        } else {
            &mut self.ai_input
        }
    }

    fn move_cursor(&mut self, delta: i32) {
        if self.rows.is_empty() {
            return;
        }
        let new =
            (self.tree_cursor as i32 + delta).clamp(0, self.rows.len() as i32 - 1) as usize;
        self.tree_cursor = new;
    }

    // -------- modal -------------------------------------------------------

    fn open_add_modal(&mut self, kind: NodeKind) {
        // 1.2.13+ Phase D.1 — Add Book under the Language
        // system book scaffolds a new conlang.  When the
        // tree cursor sits on (or inside) the Language
        // system book and the user hits `b`, we parent
        // the new Book under Language directly — bypassing
        // `pick_parent_for` which short-circuits any
        // `Book` child to "no parent" (the assumption
        // pre-dates the Language system book's nested
        // Book-under-Book shape).  The `commit_add`
        // handler then detects the Language parent and
        // runs `scaffold_language_chapters` to populate
        // the 5-chapter shell.
        if kind == NodeKind::Book {
            if let Some(lang_root_id) =
                self.system_book_id(crate::store::SYSTEM_TAG_LANGUAGES)
            {
                let cursor_id = self.rows.get(self.tree_cursor).map(|(id, _)| *id);
                let cursor_under_language = cursor_id
                    .map(|id| {
                        let mut cur = Some(id);
                        while let Some(c) = cur {
                            if c == lang_root_id {
                                return true;
                            }
                            cur = self.hierarchy.get(c).and_then(|n| n.parent_id);
                        }
                        false
                    })
                    .unwrap_or(false);
                if cursor_under_language {
                    let lang_root = self.hierarchy.get(lang_root_id).cloned();
                    let parent_label = lang_root
                        .as_ref()
                        .map(|n| self.hierarchy.slug_path(n))
                        .unwrap_or_else(|| "language".into());
                    self.modal = Modal::Adding {
                        kind,
                        parent_id: Some(lang_root_id),
                        parent_label,
                        input: TextInput::new(),
                        position: InsertPosition::End,
                    };
                    self.status =
                        "new language — type a name, Enter to scaffold; Esc to cancel".into();
                    return;
                }
            }
            // User-added Books at root always slot ABOVE the system block
            // ([Notes, Research, Prompts, Places, Characters, Help, Language])
            // so the user's own content stays at the top of the tree.
            if let Some(notes_id) = self.system_book_id(crate::store::SYSTEM_TAG_NOTES) {
                self.open_add_modal_inner(kind, InsertPosition::Before(notes_id));
                return;
            }
        }
        self.open_add_modal_inner(kind, InsertPosition::End);
    }

    /// Build a "Book › Chapter › Subchapter" style breadcrumb of titles
    /// for the node identified by `id`. Used by the search-results overlay
    /// and the Help RAG context block so users see human names rather than
    /// the slug-derived filesystem path. Falls back to the hit's own slug
    /// path if the node has vanished from the hierarchy (e.g. just deleted).
    fn title_breadcrumb(&self, id: Uuid) -> String {
        let Some(node) = self.hierarchy.get(id) else {
            return String::new();
        };
        let mut parts: Vec<String> = self
            .hierarchy
            .ancestors(node)
            .into_iter()
            .map(|n| n.title.clone())
            .collect();
        parts.push(node.title.clone());
        parts.join(" › ")
    }

    /// Look up a system book's UUID by tag. Returns None if the project
    /// pre-dates the system-book feature and the book hasn't been seeded
    /// yet (shouldn't happen in practice — ensure_system_books runs on
    /// every Store::open).
    fn system_book_id(&self, tag: &str) -> Option<Uuid> {
        self.hierarchy
            .iter()
            .find(|n| {
                n.kind == NodeKind::Book && n.system_tag.as_deref() == Some(tag)
            })
            .map(|n| n.id)
    }

    /// 1.2.13+ Phase D.1 hotfix — walk up `parent`'s
    /// ancestor chain and return the per-language Book
    /// (child of the Language system book) IFF the
    /// parent sits anywhere under a chapter titled
    /// "Dictionary" under that language.  Used by
    /// `commit_add` to route Paragraph additions
    /// through `add_dictionary_entry_impl` so they
    /// land in the right alphabet bucket with the
    /// HJSON template seeded.
    fn find_language_book_for_dictionary_paragraph(
        &self,
        parent: Option<&crate::store::node::Node>,
    ) -> Option<crate::store::node::Node> {
        let lang_root_id =
            self.system_book_id(crate::store::SYSTEM_TAG_LANGUAGES)?;
        let parent = parent?;
        // Walk up: parent → ancestor that's a Chapter
        // named "Dictionary" → ancestor that's a Book
        // (the per-language sub-book) whose parent is
        // the Language system book.
        let mut saw_dictionary = false;
        let mut cur: Option<&crate::store::node::Node> = Some(parent);
        while let Some(node) = cur {
            if !saw_dictionary
                && node.kind == NodeKind::Chapter
                && node.title.eq_ignore_ascii_case("Dictionary")
            {
                saw_dictionary = true;
            }
            if saw_dictionary
                && node.kind == NodeKind::Book
                && node.parent_id == Some(lang_root_id)
            {
                return Some(node.clone());
            }
            cur = node.parent_id.and_then(|id| self.hierarchy.get(id));
        }
        None
    }

    /// 1.2.13+ Phase D.1 hotfix — like
    /// `find_language_book_for_dictionary_paragraph`,
    /// but for Grammar / Phonology chapters.  Returns
    /// the template body string to seed; the rule's
    /// final parent stays whatever the user pointed
    /// at (no bucket routing — these chapters are
    /// flat).
    fn language_rule_chapter_for_paragraph(
        &self,
        parent: Option<&crate::store::node::Node>,
    ) -> Option<&'static str> {
        let lang_root_id =
            self.system_book_id(crate::store::SYSTEM_TAG_LANGUAGES)?;
        let parent = parent?;
        let mut matched_template: Option<&'static str> = None;
        let mut cur: Option<&crate::store::node::Node> = Some(parent);
        while let Some(node) = cur {
            if matched_template.is_none() && node.kind == NodeKind::Chapter {
                if node.title.eq_ignore_ascii_case("Grammar") {
                    matched_template =
                        Some(crate::cli::language::GRAMMAR_RULE_SEED_BODY);
                } else if node.title.eq_ignore_ascii_case("Phonology") {
                    matched_template =
                        Some(crate::cli::language::PHONOLOGY_RULE_SEED_BODY);
                }
            }
            if matched_template.is_some()
                && node.kind == NodeKind::Book
                && node.parent_id == Some(lang_root_id)
            {
                return matched_template;
            }
            cur = node.parent_id.and_then(|id| self.hierarchy.get(id));
        }
        None
    }

    /// 1.2.14+ Phase A.1 — detect whether `parent` sits
    /// anywhere under the `Threads` system book.  Drives
    /// `commit_add`'s template-seeding for the
    /// `+`-on-Threads tree-pane flow.  Returns true when
    /// the parent is the Threads root itself, a Chapter
    /// the author added beneath it for grouping, a
    /// Subchapter under that Chapter, etc.
    fn parent_is_under_threads(
        &self,
        parent: Option<&crate::store::node::Node>,
    ) -> bool {
        let Some(threads_root_id) =
            self.system_book_id(crate::store::SYSTEM_TAG_THREADS)
        else {
            return false;
        };
        let Some(parent) = parent else {
            return false;
        };
        let mut cur: Option<&crate::store::node::Node> = Some(parent);
        while let Some(node) = cur {
            if node.id == threads_root_id {
                return true;
            }
            cur = node.parent_id.and_then(|id| self.hierarchy.get(id));
        }
        false
    }

    /// Insert-after variant: walks up from the tree cursor to find a node of
    /// the same `kind` as the one being added; if found, the new node will be
    /// placed immediately after it. Falls back to append-at-end if no
    /// same-kind ancestor exists (e.g. pressing P with cursor on a book).
    fn open_add_modal_after(&mut self, kind: NodeKind) {
        let cursor_id = self.rows.get(self.tree_cursor).map(|(id, _)| *id);
        let anchor = cursor_id.and_then(|id| {
            let mut cur = Some(id);
            while let Some(c) = cur {
                let node = self.hierarchy.get(c)?;
                if node.kind == kind {
                    return Some(node.id);
                }
                cur = node.parent_id;
            }
            None
        });
        let position = match anchor {
            Some(id) => InsertPosition::After(id),
            None => InsertPosition::End,
        };
        self.open_add_modal_inner(kind, position);
    }

    fn open_add_modal_inner(&mut self, kind: NodeKind, position: InsertPosition) {
        // For After(anchor), the parent is anchor.parent_id (always valid
        // because the anchor's a same-kind node). For End, walk up to find a
        // valid parent.
        let parent_node = match position {
            InsertPosition::After(anchor_id) | InsertPosition::Before(anchor_id) => {
                match self.hierarchy.get(anchor_id) {
                    Some(anchor) => anchor.parent_id.and_then(|pid| self.hierarchy.get(pid)),
                    None => {
                        self.status = "anchor for insert-around vanished from hierarchy".into();
                        return;
                    }
                }
            }
            InsertPosition::End => {
                let cursor_id = self.rows.get(self.tree_cursor).map(|(id, _)| *id);
                match self.hierarchy.pick_parent_for(&self.cfg, cursor_id, kind) {
                    Ok(p) => p,
                    Err(e) => {
                        self.status = format!("can't add {}: {e}", kind.as_str());
                        return;
                    }
                }
            }
        };
        let parent_label = parent_node.map_or_else(
            || "<books root>".to_string(),
            |n| self.hierarchy.slug_path(n),
        );
        let parent_id = parent_node.map(|n| n.id);
        self.modal = Modal::Adding {
            kind,
            parent_id,
            parent_label,
            input: TextInput::new(),
            position,
        };
    }

    fn move_current(&mut self, dir: MoveDir) {
        let Some(&(id, _)) = self.rows.get(self.tree_cursor) else {
            self.status = "nothing selected".into();
            return;
        };
        let Some(node) = self.hierarchy.get(id) else {
            return;
        };
        let siblings = self.hierarchy.children_of(node.parent_id);
        let pos = match siblings.iter().position(|n| n.id == id) {
            Some(p) => p,
            None => return,
        };
        let other_pos = match dir {
            MoveDir::Up => {
                if pos == 0 {
                    self.status = format!("`{}` is already first", node.slug);
                    return;
                }
                pos - 1
            }
            MoveDir::Down => {
                if pos + 1 >= siblings.len() {
                    self.status = format!("`{}` is already last", node.slug);
                    return;
                }
                pos + 1
            }
        };
        let other_id = siblings[other_pos].id;
        match self.store.swap_siblings(&self.hierarchy, id, other_id) {
            Ok(()) => {
                self.status = format!(
                    "moved `{}` {}",
                    node.slug,
                    match dir {
                        MoveDir::Up => "up",
                        MoveDir::Down => "down",
                    }
                );
                self.reload_hierarchy();
                // Keep the cursor on the same node after the swap.
                if let Some(i) = self.rows.iter().position(|(rid, _)| *rid == id) {
                    self.tree_cursor = i;
                }
            }
            Err(e) => {
                self.status = format!("move failed: {e}");
            }
        }
    }

    fn open_find_modal(&mut self, with_replace: bool) {
        if self.opened.is_none() {
            self.status = "no paragraph open".into();
            return;
        }
        // Pre-fill with the last pattern if we have one open already.
        let mut search_input = TextInput::new();
        let mut replace_input = TextInput::new();
        if let Some(state) = self.opened.as_ref().and_then(|d| d.search.as_ref()) {
            for c in state.pattern.chars() {
                search_input.insert_char(c);
            }
            if with_replace {
                if let Some(r) = &state.replace_with {
                    for c in r.chars() {
                        replace_input.insert_char(c);
                    }
                }
            }
        }
        self.modal = Modal::FindReplace {
            search_input,
            replace_input: if with_replace {
                Some(replace_input)
            } else {
                None
            },
            focus_replace: false,
            scope_book: false,
        };
    }

    /// 1.2.22 R.3 — scan the whole manuscript for `pattern` and open the
    /// per-match review modal.  `opts` starts literal whole-word (the
    /// safe rename default) and is toggled in the modal (`w`/`i`/`x`),
    /// each toggle re-entering here.  User books only — a manuscript
    /// rename shouldn't touch Notes/Facts.
    fn open_replace_review(
        &mut self,
        pattern: String,
        replacement: String,
        opts: crate::replace::ReplaceOpts,
    ) {
        let matches = match crate::replace::scan_project(
            &self.store,
            &self.hierarchy,
            &crate::replace::ScanScope::UserBooks,
            &pattern,
            &replacement,
            opts,
        ) {
            Ok(m) => m,
            Err(e) => {
                self.status = format!("replace: {e}");
                self.modal = Modal::None;
                return;
            }
        };
        if matches.is_empty() {
            self.status = format!(
                "replace: no matches for `{pattern}` ({})",
                crate::replace::opts_label(opts)
            );
            self.modal = Modal::None;
            return;
        }
        let flat: Vec<(usize, usize)> = matches
            .iter()
            .enumerate()
            .flat_map(|(pi, pm)| (0..pm.hits.len()).map(move |hi| (pi, hi)))
            .collect();
        let n = flat.len();
        self.modal = Modal::ReplaceReview {
            pattern,
            replacement,
            opts,
            matches,
            flat,
            cursor: 0,
            skipped: std::collections::HashSet::new(),
        };
        self.status = format!(
            "replace: {n} match(es) · ↑↓ move · Space skip · w/i/x opts · Enter apply · Esc cancel"
        );
    }

    /// Flip one matching option (`opts_field` mutates a copy) and re-run
    /// the scan, reopening the review fresh.
    fn retoggle_replace_review(&mut self, flip: impl FnOnce(&mut crate::replace::ReplaceOpts)) {
        let Modal::ReplaceReview {
            pattern,
            replacement,
            opts,
            ..
        } = &self.modal
        else {
            return;
        };
        let (p, r, mut o) = (pattern.clone(), replacement.clone(), *opts);
        flip(&mut o);
        self.open_replace_review(p, r, o);
    }

    /// Key handler for the project-replace review modal.
    fn replace_review_handle_key(&mut self, key: crossterm::event::KeyEvent) {
        use crossterm::event::KeyCode;
        match key.code {
            KeyCode::Esc => {
                self.modal = Modal::None;
                self.status = "replace: cancelled".into();
            }
            KeyCode::Up => {
                if let Modal::ReplaceReview { cursor, .. } = &mut self.modal {
                    *cursor = cursor.saturating_sub(1);
                }
            }
            KeyCode::Down => {
                if let Modal::ReplaceReview { cursor, flat, .. } = &mut self.modal {
                    if !flat.is_empty() && *cursor + 1 < flat.len() {
                        *cursor += 1;
                    }
                }
            }
            KeyCode::Char(' ') => {
                if let Modal::ReplaceReview {
                    cursor,
                    flat,
                    skipped,
                    ..
                } = &mut self.modal
                {
                    if let Some(m) = flat.get(*cursor).copied() {
                        if !skipped.insert(m) {
                            skipped.remove(&m);
                        }
                    }
                }
            }
            KeyCode::Char('a') | KeyCode::Char('A') => {
                if let Modal::ReplaceReview { skipped, .. } = &mut self.modal {
                    skipped.clear();
                }
            }
            KeyCode::Char('n') | KeyCode::Char('N') => {
                if let Modal::ReplaceReview { flat, skipped, .. } = &mut self.modal {
                    *skipped = flat.iter().copied().collect();
                }
            }
            // 1.2.23 R.3 polish — toggle matching options, re-scanning.
            KeyCode::Char('w') | KeyCode::Char('W') => {
                self.retoggle_replace_review(|o| o.word_boundary = !o.word_boundary);
            }
            KeyCode::Char('i') | KeyCode::Char('I') => {
                self.retoggle_replace_review(|o| o.ignore_case = !o.ignore_case);
            }
            KeyCode::Char('x') | KeyCode::Char('X') => {
                self.retoggle_replace_review(|o| o.regex = !o.regex);
            }
            KeyCode::Enter => self.apply_replace_review(),
            _ => {}
        }
    }

    /// Apply the non-skipped matches from the review modal: snapshot +
    /// rewrite each touched paragraph, reload the open buffer if it was
    /// among them.
    fn apply_replace_review(&mut self) {
        let (pattern, replacement, selected) = match &self.modal {
            Modal::ReplaceReview {
                pattern,
                replacement,
                matches,
                skipped,
                ..
            } => {
                let mut sel: Vec<crate::replace::ParaMatches> = Vec::new();
                for (pi, pm) in matches.iter().enumerate() {
                    let hits: Vec<crate::replace::Hit> = pm
                        .hits
                        .iter()
                        .enumerate()
                        .filter(|(hi, _)| !skipped.contains(&(pi, *hi)))
                        .map(|(_, h)| h.clone())
                        .collect();
                    if !hits.is_empty() {
                        sel.push(crate::replace::ParaMatches {
                            para_id: pm.para_id,
                            title: pm.title.clone(),
                            slug_path: pm.slug_path.clone(),
                            body: pm.body.clone(),
                            hits,
                        });
                    }
                }
                (pattern.clone(), replacement.clone(), sel)
            }
            _ => return,
        };
        if selected.is_empty() {
            self.status = "replace: every match was skipped — nothing to do".into();
            self.modal = Modal::None;
            return;
        }
        let annotation = format!("replace: {pattern} → {replacement}");
        let report = match crate::replace::apply_project(
            &self.store,
            &self.hierarchy,
            &selected,
            &annotation,
        ) {
            Ok(r) => r,
            Err(e) => {
                self.status = format!("replace: {e}");
                self.modal = Modal::None;
                return;
            }
        };
        self.modal = Modal::None;
        // Reload the open paragraph if it was among the rewritten ones.
        if let Some(open_id) = self.opened.as_ref().map(|d| d.id) {
            if selected.iter().any(|pm| pm.para_id == open_id) {
                let _ = self.open_paragraph_by_uuid(open_id);
            }
        }
        self.status = format!(
            "replace: applied {} in {} paragraph(s); {} snapshot(s) taken (F6 to restore)",
            report.occurrences, report.paragraphs, report.snapshots,
        );
    }

    fn commit_find(&mut self) {
        let (pattern, replace_with, scope_book) = match &self.modal {
            Modal::FindReplace {
                search_input,
                replace_input,
                scope_book,
                ..
            } => (
                search_input.as_str().to_string(),
                replace_input.as_ref().map(|i| i.as_str().to_string()),
                *scope_book,
            ),
            _ => return,
        };
        if pattern.is_empty() {
            self.status = "search pattern is empty".into();
            return;
        }
        // 1.2.22 R.3 — project (book) scope: scan the whole manuscript
        // and open the per-match review instead of the in-buffer search.
        if scope_book {
            match replace_with {
                Some(repl) => self.open_replace_review(
                    pattern,
                    repl,
                    crate::replace::ReplaceOpts::default(),
                ),
                None => {
                    self.status = "book replace needs a replacement (Ctrl+R)".into()
                }
            }
            return;
        }
        let Some(doc) = self.opened.as_mut() else {
            self.modal = Modal::None;
            return;
        };
        let lines = doc.textarea.lines().to_vec();
        match SearchState::build(&pattern, replace_with.clone(), &lines) {
            Ok(state) => {
                let n = state.matches.len();
                doc.search = Some(state);
                self.modal = Modal::None;
                if n == 0 {
                    self.status = format!("no matches for /{pattern}/");
                    return;
                }
                // Jump cursor to first match
                self.jump_to_current_match();
                if replace_with.is_some() {
                    // Replace mode: do the FIRST replacement automatically so
                    // user sees immediate effect; subsequent Ctrl+G keep going.
                    self.do_replace_current();
                    let remaining = self
                        .opened
                        .as_ref()
                        .and_then(|d| d.search.as_ref())
                        .map_or(0, |s| s.matches.len());
                    self.status = format!(
                        "/{pattern}/ → replaced 1 · {remaining} left · Ctrl+G next · Ctrl+R replace all · Esc clear"
                    );
                } else {
                    self.status = format!(
                        "/{pattern}/ → {n} match(es) · Ctrl+G next · Ctrl+R add replacement · Esc clear"
                    );
                }
            }
            Err(e) => {
                self.status = format!("regex error: {e}");
                // Leave the modal open so the user can fix the pattern.
            }
        }
    }

    fn jump_to_current_match(&mut self) {
        let target = self
            .opened
            .as_ref()
            .and_then(|d| d.search.as_ref())
            .and_then(|s| s.current_match())
            .map(|m| (m.row, m.col_start));
        let Some((row, col)) = target else {
            return;
        };
        // Editor viewport height in lines — `layout_editor` is cached
        // from the last draw and includes the two border rows.
        let viewport_h =
            (self.layout_editor.height as usize).saturating_sub(2);
        if let Some(doc) = self.opened.as_mut() {
            doc.textarea
                .move_cursor(CursorMove::Jump(row as u16, col as u16));
            // The editor draws itself — it doesn't render the
            // tui-textarea widget — so the actual viewport top is
            // `doc.scroll_row`, not anything inside tui-textarea. The
            // per-render auto-scroll only nudges `scroll_row` when the
            // cursor falls outside `[scroll_row, scroll_row + h)`,
            // which after a forward Jump lands the cursor at the
            // BOTTOM edge of the viewport. Pre-pinning scroll_row to
            // `target - h/2` keeps the cursor inside the new viewport,
            // so the auto-scroll leaves it alone and the match lands
            // in the middle.
            //
            // Both renderers track scroll_row this way (unwrapped uses
            // source rows verbatim; wrapped uses visual rows but for
            // typical short-line literary content the two agree).
            if viewport_h > 0 {
                let half = viewport_h / 2;
                doc.scroll_row = row.saturating_sub(half);
            }
        }
    }

    fn search_advance_or_replace(&mut self) {
        let is_replace = self
            .opened
            .as_ref()
            .and_then(|d| d.search.as_ref())
            .is_some_and(|s| s.replace_with.is_some());
        if is_replace {
            // Replace the current match, refresh hits, jump to new first.
            self.do_replace_current();
            self.refresh_search_after_edit();
            let remaining = self
                .opened
                .as_ref()
                .and_then(|d| d.search.as_ref())
                .map_or(0, |s| s.matches.len());
            if remaining == 0 {
                if let Some(doc) = self.opened.as_mut() {
                    doc.search = None;
                }
                self.status = "replace done — no more matches".into();
            } else {
                self.jump_to_current_match();
                self.status = format!("replaced · {remaining} left · Ctrl+R replace all");
            }
        } else {
            // Search-only: advance to next match (wraps).
            if let Some(doc) = self.opened.as_mut() {
                if let Some(state) = doc.search.as_mut() {
                    state.advance();
                }
            }
            self.jump_to_current_match();
            let n = self
                .opened
                .as_ref()
                .and_then(|d| d.search.as_ref())
                .map_or(0, |s| s.matches.len());
            let i = self
                .opened
                .as_ref()
                .and_then(|d| d.search.as_ref())
                .map_or(0, |s| s.current);
            if n > 0 {
                self.status = format!("match {} / {}", i + 1, n);
            }
        }
    }

    fn do_replace_current(&mut self) {
        let (row, col_start, col_end, replacement) = {
            let Some(doc) = self.opened.as_ref() else {
                return;
            };
            let Some(state) = &doc.search else {
                return;
            };
            let Some(replacement) = state.replace_with.clone() else {
                return;
            };
            let Some(m) = state.current_match() else {
                return;
            };
            (m.row, m.col_start, m.col_end, replacement)
        };
        let Some(doc) = self.opened.as_mut() else {
            return;
        };
        // Select [col_start..col_end] in row, cut, insert replacement.
        doc.textarea
            .move_cursor(CursorMove::Jump(row as u16, col_start as u16));
        doc.textarea.start_selection();
        doc.textarea
            .move_cursor(CursorMove::Jump(row as u16, col_end as u16));
        doc.textarea.cut();
        doc.textarea.insert_str(&replacement);
        doc.dirty = true;
    }

    fn refresh_search_after_edit(&mut self) {
        let lines = self
            .opened
            .as_ref()
            .map(|d| d.textarea.lines().to_vec());
        let Some(lines) = lines else { return };
        if let Some(doc) = self.opened.as_mut() {
            if let Some(state) = doc.search.as_mut() {
                state.refresh(&lines);
            }
        }
    }

    fn replace_all_remaining(&mut self) {
        let mut count = 0;
        loop {
            let has_any = self
                .opened
                .as_ref()
                .and_then(|d| d.search.as_ref())
                .map_or(false, |s| !s.matches.is_empty());
            if !has_any {
                break;
            }
            self.do_replace_current();
            self.refresh_search_after_edit();
            count += 1;
            if count > 100_000 {
                break;
            }
        }
        if let Some(doc) = self.opened.as_mut() {
            doc.search = None;
        }
        self.status = format!("replaced {count} match(es) and cleared search");
    }

    /// Toggle split-edit mode. When entering, capture the current buffer as
    /// the lower-pane snapshot; when leaving, drop the snapshot and restore
    /// full-size editor.
    fn toggle_split(&mut self) {
        let Some(doc) = self.opened.as_mut() else {
            self.status = "no paragraph open".into();
            return;
        };
        if doc.split.is_some() {
            doc.split = None;
            self.status = "split closed".into();
        } else {
            let snapshot_lines = doc.textarea.lines().to_vec();
            doc.split = Some(SplitView {
                snapshot_lines,
                scroll_row: 0,
            });
            self.status =
                "split open · upper r/w · lower r/o · Ctrl+H/J scroll · Ctrl+F4 accept · F4 close"
                    .into();
        }
    }

    fn scroll_split_up(&mut self) {
        if let Some(doc) = self.opened.as_mut() {
            if let Some(split) = &mut doc.split {
                split.scroll_row = split.scroll_row.saturating_sub(1);
            }
        }
    }

    fn scroll_split_down(&mut self) {
        if let Some(doc) = self.opened.as_mut() {
            if let Some(split) = &mut doc.split {
                let max = split.snapshot_lines.len().saturating_sub(1);
                if split.scroll_row < max {
                    split.scroll_row += 1;
                }
            }
        }
    }

    /// Dispatch the keystroke that follows the meta-prefix. Each pane has
    /// its own action table:
    ///   * Tree (and Search bar): hierarchy operations
    ///   * Editor: save / snapshots / file load / split-edit
    ///   * AI (and AI prompt): inference management
    fn handle_meta_action(&mut self, key: KeyEvent) {
        self.meta_pending = false;
        if matches!(key.code, KeyCode::Esc) {
            self.status = "meta cancelled".into();
            return;
        }
        let plain = !key
            .modifiers
            .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER);
        if !plain {
            self.status = "meta cancelled".into();
            return;
        }
        let resolved = super::keybind::read().resolve_meta_sub(&key, self.focus);
        match resolved {
            Some(super::keybind::Action::None) => {
                self.status = "meta: chord disabled by config".into();
            }
            Some(action) => self.run_action(action),
            None => {
                self.status = format!(
                    "meta {}: unknown action — use Ctrl+B again to retry",
                    self.focus.label()
                );
            }
        }
    }

    /// Dispatch the Bund-meta chord (Ctrl+Z by default). Three
    /// actions in the v1 chord table:
    ///
    ///   R — run the buffer (eval the open Script's body against Adam)
    ///   N — new script (Add modal under the Scripts system book)
    ///   E — eval one expression (modal prompt, like F1)
    /// Dispatch the view-prefix chord (Ctrl+V): markdown export.
    /// Two suffixes are recognised; their meaning shifts by focus:
    ///
    ///   * Editor pane + `1`  → current paragraph buffer (in-memory,
    ///     includes unsaved edits).
    ///   * Editor pane + `2`  → containing subchapter's subtree.
    ///   * Tree pane   + `1`  → tree-cursor's node and all descendants.
    ///
    /// All variants run the source through `typst_to_markdown`
    /// and write to the launch cwd with a `<title>-<stamp>.md`
    /// filename. Errors land on the status bar; nothing else
    /// changes.
    /// Resolve the tree-cursor row to a paragraph and link it to
    /// the link-pick owner. Always exits pick mode (success or
    /// failure) and returns focus to the editor. Direction is
    /// stashed in `link_pick_for.1`:
    /// * `Outgoing` (Ctrl+V A) — `add_link(owner, picked)`
    /// * `Incoming` (Ctrl+V I) — `add_link(picked, owner)`
    fn commit_link_pick(&mut self) {
        let Some((owner, direction)) = self.link_pick_for else { return };
        let picked = self
            .rows
            .get(self.tree_cursor)
            .map(|(id, _)| *id);
        self.link_pick_for = None;
        let Some(picked) = picked else {
            self.status = "link cancelled: no tree row selected".into();
            self.change_focus(Focus::Editor);
            return;
        };
        let picked_kind = self.hierarchy.get(picked).map(|n| n.kind);
        if !matches!(picked_kind, Some(NodeKind::Paragraph)) {
            self.status =
                "link cancelled: target is not a paragraph".into();
            self.change_focus(Focus::Editor);
            return;
        }
        // Outgoing: owner → picked.  Incoming: picked → owner.
        let (from, to) = match direction {
            LinkPickDirection::Outgoing => (owner, picked),
            LinkPickDirection::Incoming => (picked, owner),
        };
        match self.add_paragraph_link(from, to) {
            Ok(()) => {
                let title = self
                    .hierarchy
                    .get(to)
                    .map(|n| n.title.clone())
                    .unwrap_or_else(|| "?".into());
                self.status = match direction {
                    LinkPickDirection::Outgoing => format!("linked → `{title}`"),
                    LinkPickDirection::Incoming => {
                        let from_title = self
                            .hierarchy
                            .get(from)
                            .map(|n| n.title.clone())
                            .unwrap_or_else(|| "?".into());
                        format!("linked `{from_title}` → current")
                    }
                };
            }
            Err(e) => {
                self.status = format!("link: {e}");
            }
        }
        self.change_focus(Focus::Editor);
    }

    // ── Paragraph links (1.2.4+) ────────────────────────────────

    /// Enter "select paragraph to link" mode. Tree pane gets a
    /// custom title; Enter on a paragraph adds it to the open
    /// paragraph's outgoing links (with a circular-reference
    /// guard). Esc / loss-of-focus cancels.
    fn enter_link_pick_mode(&mut self) {
        let Some(doc) = self.opened.as_ref() else {
            self.status = "view A: no paragraph open".into();
            return;
        };
        self.link_pick_for = Some((doc.id, LinkPickDirection::Outgoing));
        self.change_focus(Focus::Tree);
        self.status =
            "link: select paragraph to link · Enter confirms · Esc cancels".into();
    }

    /// Reverse-direction picker (1.2.4+, Ctrl+V I). The
    /// tree-picked paragraph's outgoing links gains the open
    /// paragraph — same circular guard.
    fn enter_incoming_link_pick_mode(&mut self) {
        let Some(doc) = self.opened.as_ref() else {
            self.status = "view I: no paragraph open".into();
            return;
        };
        self.link_pick_for = Some((doc.id, LinkPickDirection::Incoming));
        self.change_focus(Focus::Tree);
        self.status =
            "incoming link: select paragraph that will link to current · Enter confirms · Esc cancels"
                .into();
    }

    /// Open the linked-paragraphs modal for the open paragraph.
    /// Lists each outgoing link with title + slug path; `D`
    /// removes a row.
    fn open_link_picker_modal(&mut self) {
        let Some(doc) = self.opened.as_ref() else {
            self.status = "view L: no paragraph open".into();
            return;
        };
        let owner = doc.id;
        let entries = self.collect_link_entries(owner);
        if entries.is_empty() {
            self.status =
                "view L: no linked paragraphs (Ctrl+V A adds one)".into();
            return;
        }
        self.modal = Modal::LinkPicker {
            owner,
            entries,
            cursor: 0,
            scroll: 0,
        };
        self.status =
            "links: ↑↓ select · D removes · Esc closes".into();
    }

    /// Toggle bookmark on the open paragraph. Persists via the
    /// existing metadata-update path (`Store::raw().update_metadata`).
    fn toggle_bookmark(&mut self) {
        let Some(doc) = self.opened.as_ref() else {
            self.status = "view B: no paragraph open".into();
            return;
        };
        let id = doc.id;
        let Some(node) = self.hierarchy.get(id).cloned() else {
            self.status = "view B: paragraph not in hierarchy".into();
            return;
        };
        let new_value = !node.bookmark;
        let mut updated = node.clone();
        updated.bookmark = new_value;
        updated.modified_at = chrono::Utc::now();
        if let Err(e) = self
            .store
            .raw()
            .update_metadata(id, updated.to_json())
        {
            self.status = format!("bookmark: store update failed: {e}");
            return;
        }
        self.reload_hierarchy();
        self.status = format!(
            "bookmark {}: `{}`",
            if new_value { "added" } else { "cleared" },
            node.title
        );
    }

    /// Open the bookmark picker — every paragraph whose
    /// `bookmark` flag is true.
    fn open_bookmark_picker_modal(&mut self) {
        let entries = self.collect_bookmark_entries();
        if entries.is_empty() {
            self.status =
                "view M: no bookmarks (Ctrl+V B toggles one on the open paragraph)".into();
            return;
        }
        self.modal = Modal::BookmarkPicker {
            entries,
            cursor: 0,
            scroll: 0,
        };
        self.status =
            "bookmarks: ↑↓ select · Enter opens · Shift+Enter pins to split · D clears · Esc closes"
                .into();
    }

    /// Open the fuzzy paragraph picker. Pre-builds the full
    /// paragraph list (title + slug-path) so subsequent
    /// keystrokes only filter, never re-walk the hierarchy.
    /// Ctrl+V R (1.2.5+) — render the open paragraph in-process,
    /// pop a floating preview modal on top of the editor. Saves
    /// the current buffer first so the rendered preview matches
    /// the on-disk source the user just edited.
    /// Ctrl+V W (1.2.5+) — story view. Build the DOT graph for
    /// the current user book, lay it out, rasterise, and float
    /// the PNG on top of the editor. Saves the open paragraph
    /// first so any pending mentions get scanned.
    fn open_story_view(&mut self) {
        // Pick the same "current book" the assemble/build/take
        // path uses. Refuses if the cursor isn't inside a user
        // book.
        let Some(book_id) = self.resolve_current_user_book("Story view") else {
            return;
        };
        // Save first so mention-scanning sees the latest body.
        if let Some(doc) = self.opened.as_ref() {
            if doc.dirty {
                let _ = self.save_current();
            }
        }
        let Some(picker) = self.image_picker.as_ref() else {
            self.status =
                "story view: terminal can't display images (set `images.preview_enabled: true` or use a kitty / iterm2 / sixel-capable terminal)".into();
            return;
        };
        let book_title = self
            .hierarchy
            .get(book_id)
            .map(|n| n.title.clone())
            .unwrap_or_else(|| "(unknown book)".into());
        self.status = format!(
            "story view: building graph for `{book_title}`…"
        );
        match crate::story_view::build_story_png(
            &self.store,
            &self.hierarchy,
            book_id,
        ) {
            Ok(rendered) => {
                let proto = picker.new_resize_protocol(rendered.image);
                self.modal = Modal::StoryView {
                    book_title: book_title.clone(),
                    width: rendered.width,
                    height: rendered.height,
                    png_bytes: rendered.png_bytes,
                    proto,
                };
                self.status = format!(
                    "story view `{book_title}` · {}×{} · S saves PNG · Esc closes",
                    rendered.width, rendered.height,
                );
            }
            Err(err) => {
                let first = err
                    .lines()
                    .find(|l| !l.trim().is_empty())
                    .unwrap_or("render failed");
                self.status = format!("story view: {first}");
            }
        }
    }

    /// Ctrl+V w (1.2.6+) — paragraph mini story view. Same
    /// pipeline as `open_story_view`, but for the open
    /// paragraph instead of the current book. Routes to
    /// `Modal::StoryView` so the save / Esc UX is identical.
    fn open_story_view_paragraph(&mut self) {
        let Some(doc) = self.opened.as_ref() else {
            self.status =
                "story view (¶): no paragraph open".into();
            return;
        };
        if doc.dirty {
            let _ = self.save_current();
        }
        let Some(doc) = self.opened.as_ref() else {
            return;
        };
        let paragraph_id = doc.id;
        let paragraph_title = doc.title.clone();
        let Some(picker) = self.image_picker.as_ref() else {
            self.status =
                "story view (¶): terminal can't display images".into();
            return;
        };
        match crate::story_view::build_story_png_for_paragraph(
            &self.store,
            &self.hierarchy,
            paragraph_id,
        ) {
            Ok(rendered) => {
                let proto = picker.new_resize_protocol(rendered.image);
                self.modal = Modal::StoryView {
                    book_title: paragraph_title.clone(),
                    width: rendered.width,
                    height: rendered.height,
                    png_bytes: rendered.png_bytes,
                    proto,
                };
                self.status = format!(
                    "story view (¶) `{paragraph_title}` · {}×{} · S saves PNG · Esc closes",
                    rendered.width, rendered.height,
                );
            }
            Err(err) => {
                let first = err
                    .lines()
                    .find(|l| !l.trim().is_empty())
                    .unwrap_or("render failed");
                self.status = format!("story view (¶): {first}");
            }
        }
    }

    /// `S` inside `Modal::StoryView` — pop the save-as picker
    /// for the rendered PNG. Default path:
    /// `<book-slug>-story-YYYYDDMM-HHMM.png` in cwd.
    fn open_save_story_png_picker(&mut self) {
        let (png_bytes, book_title) = match &self.modal {
            Modal::StoryView {
                png_bytes,
                book_title,
                ..
            } => (png_bytes.clone(), book_title.clone()),
            _ => return,
        };
        let default_dest = match self.default_story_png_dest(&book_title) {
            Ok(p) => p,
            Err(e) => {
                self.status = format!("save story PNG: {e}");
                return;
            }
        };
        let mut input = TextInput::new();
        for c in default_dest.to_string_lossy().chars() {
            input.insert_char(c);
        }
        let return_to = Box::new(std::mem::replace(&mut self.modal, Modal::None));
        self.modal = Modal::SaveStoryPng {
            input,
            png_bytes,
            book_title: book_title.clone(),
            return_to,
        };
        self.status =
            "save story PNG: edit path or Enter to save · Esc returns to preview".into();
    }

    fn default_story_png_dest(
        &self,
        book_title: &str,
    ) -> std::result::Result<std::path::PathBuf, String> {
        let cwd = std::env::current_dir().map_err(|e| format!("cwd: {e}"))?;
        let stamp = chrono::Local::now().format("%Y%d%m-%H%M");
        let stem = slug::slugify(book_title);
        let safe_stem =
            if stem.is_empty() { "story".to_string() } else { stem };
        Ok(cwd.join(format!("{safe_stem}-story-{stamp}.png")))
    }

    /// Write the already-rendered PNG bytes to disk. No re-render
    /// — the layout is deterministic and the same bytes the
    /// preview displays are what land on disk.
    fn commit_save_story_png(
        &mut self,
        png_bytes: &[u8],
        raw: &str,
        book_title: &str,
    ) {
        let path_str = raw.trim();
        let path = if path_str.is_empty() {
            match self.default_story_png_dest(book_title) {
                Ok(p) => p,
                Err(e) => {
                    self.status = format!("save story PNG: {e}");
                    return;
                }
            }
        } else if let Some(rest) = path_str.strip_prefix("~/") {
            match std::env::var_os("HOME") {
                Some(home) => std::path::PathBuf::from(home).join(rest),
                None => std::path::PathBuf::from(path_str),
            }
        } else {
            std::path::PathBuf::from(path_str)
        };
        match std::fs::write(&path, png_bytes) {
            Ok(()) => {
                self.status = format!(
                    "save story PNG: wrote {} ({} bytes)",
                    path.display(),
                    png_bytes.len(),
                );
            }
            Err(e) => {
                self.status =
                    format!("save story PNG: write {}: {e}", path.display());
            }
        }
    }

    fn open_rendered_paragraph_preview(&mut self) {
        let Some(doc) = self.opened.as_ref() else {
            self.status =
                "render ¶: no paragraph open in the editor".into();
            return;
        };
        // Skip Bund / HJSON / images — only typst sources render
        // through the typst pipeline.
        let is_typst = matches!(
            doc.content_type.as_deref(),
            None | Some("") | Some("typst"),
        );
        if !is_typst {
            self.status = format!(
                "render ¶: `{}` is not a typst source — Ctrl+V R only renders .typ buffers",
                doc.title,
            );
            return;
        }
        // Capture the title before we save (save may renumber /
        // re-derive the title from the first sentence).
        let title_before = doc.title.clone();
        // Save first — the spec says "Save current buffer" before
        // render. If save fails, abort the render (we shouldn't
        // render bytes that aren't on disk).
        if doc.dirty {
            if let Err(e) = self.save_current() {
                self.status =
                    format!("render ¶: autosave failed: {e}");
                return;
            }
        }
        let Some(doc) = self.opened.as_ref() else {
            self.status = "render ¶: editor closed during save".into();
            return;
        };
        let body = doc.textarea.lines().join("\n");
        let title = doc.title.clone();
        if body.trim().is_empty() {
            self.status =
                "render ¶: buffer is empty — nothing to render".into();
            return;
        }
        let _ = title_before;
        // Image picker (ratatui-image) is required to display the
        // PNG. Without it (terminals without graphics support and
        // `images.preview_enabled = false`) fall back to status
        // bar with a hint.
        let Some(picker) = self.image_picker.as_ref() else {
            self.status =
                "render ¶: terminal can't display images (set `images.preview_enabled: true` or use a kitty / iterm2 / sixel-capable terminal)".into();
            return;
        };
        let settings = crate::typst_world::WorldSettings::from_cfg(
            &self.cfg.typst_compile,
        );
        // Preview DPI: 2.0 ppt = ~144 dpi. Good for screen,
        // doesn't blow up memory on long paragraphs. Renders
        // every page up front so Left/Right inside the modal is
        // a pure protocol swap (no re-compile).
        match crate::typst_paragraph_render::render_all(
            &body,
            settings.clone(),
            2.0,
        ) {
            Ok(rendered) => {
                let total = rendered.len();
                let first_w = rendered[0].width;
                let first_h = rendered[0].height;
                let pages: Vec<RenderedPageProto> = rendered
                    .into_iter()
                    .map(|r| RenderedPageProto {
                        proto: picker.new_resize_protocol(r.image),
                        width: r.width,
                        height: r.height,
                    })
                    .collect();
                self.modal = Modal::RenderedPreview {
                    title: title.clone(),
                    body,
                    settings,
                    pages,
                    current_page: 0,
                    ppi: 2.0,
                };
                let pages_note = if total > 1 {
                    format!(" · page 1/{}  · ←/→ navigate", total)
                } else {
                    String::new()
                };
                self.status = format!(
                    "render ¶ `{}` · {}×{}{}  ·  +/- zoom · Esc closes · S saves current · A saves all",
                    title, first_w, first_h, pages_note,
                );
            }
            Err(err) => {
                let first_line = err
                    .lines()
                    .find(|l| !l.trim().is_empty())
                    .unwrap_or("compile failed");
                self.status =
                    format!("render ¶: {first_line}");
            }
        }
    }

    fn open_fuzzy_paragraph_picker(&mut self) {
        let entries = self.collect_all_paragraph_entries();
        if entries.is_empty() {
            self.status =
                "view P: no paragraphs in this project".into();
            return;
        }
        self.modal = Modal::FuzzyParagraphPicker {
            input: TextInput::new(),
            entries,
            cursor: 0,
            scroll: 0,
        };
        self.status =
            "find ¶: type to filter · ↑↓ select · Enter opens · Shift+Enter pins to split · Esc".into();
    }

    /// 1.2.7+ — Ctrl+V Shift+P. Same fuzzy picker as
    /// `Ctrl+V p` but the entry list is sorted by
    /// `modified_at desc` instead of slug-path. Answers
    /// "what did I touch most recently?" without trawling
    /// the tree.
    fn open_recent_paragraph_picker(&mut self) {
        let mut entries = self.collect_all_paragraph_entries();
        if entries.is_empty() {
            self.status =
                "recent ¶: no paragraphs in this project".into();
            return;
        }
        // Sort by modified_at desc. Look up modified_at via
        // the hierarchy — the picker entry struct doesn't
        // carry it, so we re-resolve here.
        let modified: std::collections::HashMap<Uuid, chrono::DateTime<chrono::Utc>> =
            self.hierarchy
                .iter()
                .map(|n| (n.id, n.modified_at))
                .collect();
        entries.sort_by(|a, b| {
            let ta = modified.get(&a.id).copied()
                .unwrap_or_else(chrono::Utc::now);
            let tb = modified.get(&b.id).copied()
                .unwrap_or_else(chrono::Utc::now);
            tb.cmp(&ta)
        });
        self.modal = Modal::FuzzyParagraphPicker {
            input: TextInput::new(),
            entries,
            cursor: 0,
            scroll: 0,
        };
        self.status =
            "recent ¶: most-recently-modified first · ↑↓ select · Enter opens · Shift+Enter pins to split · Esc".into();
    }

    /// Collect every paragraph in the project (excluding
    /// system-book content) as picker entries.
    fn collect_all_paragraph_entries(&self) -> Vec<ScriptPickerEntry> {
        let mut out: Vec<ScriptPickerEntry> = Vec::new();
        for (n, _) in self.hierarchy.flatten() {
            if n.kind != NodeKind::Paragraph {
                continue;
            }
            // Skip system-book content — Help reference, Typst
            // reference, etc., aren't manuscript paragraphs.
            let in_system = self
                .hierarchy
                .ancestors(n)
                .into_iter()
                .any(|a| a.kind == NodeKind::Book && a.system_tag.is_some());
            if in_system {
                continue;
            }
            out.push(ScriptPickerEntry {
                id: n.id,
                title: n.title.clone(),
                slug_path: self.hierarchy.slug_path(n),
            });
        }
        out.sort_by(|a, b| a.slug_path.cmp(&b.slug_path));
        out
    }

    /// Walk the hierarchy and collect every paragraph whose
    /// `bookmark` flag is true.
    fn collect_bookmark_entries(&self) -> Vec<ScriptPickerEntry> {
        let mut out: Vec<ScriptPickerEntry> = Vec::new();
        for (n, _) in self.hierarchy.flatten() {
            if n.kind != NodeKind::Paragraph || !n.bookmark {
                continue;
            }
            out.push(ScriptPickerEntry {
                id: n.id,
                title: n.title.clone(),
                slug_path: self.hierarchy.slug_path(n),
            });
        }
        out.sort_by(|a, b| a.title.cmp(&b.title));
        out
    }

    // ── Tag picker (1.2.5+) ────────────────────────────────────

    /// Every distinct tag in the project, sorted lexicographically
    /// (case-sensitive dedup). System-book contents are included
    /// in the union so the tag namespace is project-wide.
    fn collect_all_tags(&self) -> Vec<String> {
        let mut tags = std::collections::BTreeSet::<String>::new();
        for (n, _) in self.hierarchy.flatten() {
            for t in &n.tags {
                let t = t.trim();
                if !t.is_empty() {
                    tags.insert(t.to_owned());
                }
            }
        }
        tags.into_iter().collect()
    }

    /// Paragraphs tagged with `tag` (case-sensitive match). Sorted
    /// by title to match the bookmark picker's ordering.
    fn collect_paragraphs_with_tag(&self, tag: &str) -> Vec<ScriptPickerEntry> {
        let mut out: Vec<ScriptPickerEntry> = Vec::new();
        for (n, _) in self.hierarchy.flatten() {
            if n.kind != NodeKind::Paragraph {
                continue;
            }
            if !n.tags.iter().any(|t| t == tag) {
                continue;
            }
            out.push(ScriptPickerEntry {
                id: n.id,
                title: n.title.clone(),
                slug_path: self.hierarchy.slug_path(n),
            });
        }
        out.sort_by(|a, b| a.title.cmp(&b.title));
        out
    }

    /// Union `incoming` into `node_id`'s `tags` (dedup case-
    /// sensitively, preserve existing order), persist via
    /// `update_metadata`. Returns true on a successful save.
    /// 1.2.6+: set the node's `tags` to exactly `incoming` (a
    /// set-replace, not a union). Used by single-paragraph picker
    /// commits so unchecking a tag actually removes it. Returns
    /// false on persist failure.
    fn set_tags_on_node(&mut self, node_id: Uuid, incoming: &[String]) -> bool {
        let Some(node) = self.hierarchy.get(node_id).cloned() else {
            return false;
        };
        let mut updated = node;
        let mut next: Vec<String> = incoming
            .iter()
            .map(|s| s.trim().to_owned())
            .filter(|s| !s.is_empty())
            .collect();
        next.sort();
        next.dedup();
        // Skip the write when nothing actually changed — saves the
        // `modified_at` bump and a needless hierarchy reload.
        if updated.tags == next {
            return true;
        }
        updated.tags = next;
        updated.modified_at = chrono::Utc::now();
        match self.store.raw().update_metadata(node_id, updated.to_json()) {
            Ok(_) => true,
            Err(e) => {
                tracing::warn!(target: "inkhaven::tags",
                    "update_metadata({node_id}) failed: {e}");
                false
            }
        }
    }

    fn add_tags_to_node(&mut self, node_id: Uuid, incoming: &[String]) -> bool {
        let Some(node) = self.hierarchy.get(node_id).cloned() else {
            return false;
        };
        let mut updated = node.clone();
        let existing: std::collections::HashSet<&str> =
            updated.tags.iter().map(|s| s.as_str()).collect();
        let mut additions: Vec<String> = Vec::new();
        for t in incoming {
            let t = t.trim();
            if t.is_empty() {
                continue;
            }
            if !existing.contains(t)
                && !additions.iter().any(|a: &String| a.as_str() == t)
            {
                additions.push(t.to_owned());
            }
        }
        if additions.is_empty() {
            return true;
        }
        updated.tags.extend(additions);
        updated.modified_at = chrono::Utc::now();
        match self.store.raw().update_metadata(node_id, updated.to_json()) {
            Ok(_) => true,
            Err(e) => {
                tracing::warn!(target: "inkhaven::tags",
                    "update_metadata({node_id}) failed: {e}");
                false
            }
        }
    }

    /// Remove `tag` from every node that carries it. Returns the
    /// count of nodes touched (so the picker can report how many
    /// were affected). Persists each via `update_metadata`.
    fn delete_tag_project_wide(&mut self, tag: &str) -> usize {
        let targets: Vec<Uuid> = self
            .hierarchy
            .flatten()
            .into_iter()
            .filter_map(|(n, _)| {
                if n.tags.iter().any(|t| t == tag) {
                    Some(n.id)
                } else {
                    None
                }
            })
            .collect();
        let mut touched = 0usize;
        for id in &targets {
            let Some(node) = self.hierarchy.get(*id).cloned() else {
                continue;
            };
            let mut updated = node.clone();
            updated.tags.retain(|t| t != tag);
            updated.modified_at = chrono::Utc::now();
            if let Err(e) = self.store.raw().update_metadata(*id, updated.to_json()) {
                tracing::warn!(target: "inkhaven::tags",
                    "update_metadata({id}) on delete failed: {e}");
                continue;
            }
            touched += 1;
        }
        touched
    }

    /// Helper — number of nodes a tag delete would affect. Used
    /// by the delete-confirm modal so the user sees the blast
    /// radius before pressing y.
    fn count_nodes_with_tag(&self, tag: &str) -> usize {
        self.hierarchy
            .flatten()
            .into_iter()
            .filter(|(n, _)| n.tags.iter().any(|t| t == tag))
            .count()
    }

    /// Walk every node carrying `old_tag` and replace it with
    /// `new_tag`. If `new_tag` is already present on the node,
    /// the result dedupes (effectively merging the two tags
    /// into one). Returns the count of nodes touched.
    fn rename_tag_project_wide(&mut self, old_tag: &str, new_tag: &str) -> usize {
        let targets: Vec<Uuid> = self
            .hierarchy
            .flatten()
            .into_iter()
            .filter_map(|(n, _)| {
                if n.tags.iter().any(|t| t == old_tag) {
                    Some(n.id)
                } else {
                    None
                }
            })
            .collect();
        let mut touched = 0usize;
        for id in &targets {
            let Some(node) = self.hierarchy.get(*id).cloned() else {
                continue;
            };
            let mut updated = node.clone();
            // Replace + dedup: build a fresh Vec preserving
            // order, skip the old name, append the new name if
            // it isn't already present.
            let mut fresh: Vec<String> = Vec::with_capacity(updated.tags.len());
            let mut new_appended = false;
            for t in updated.tags.iter() {
                if t == old_tag {
                    if !new_appended && !fresh.iter().any(|x| x == new_tag) {
                        fresh.push(new_tag.to_owned());
                        new_appended = true;
                    }
                } else if !fresh.iter().any(|x| x == t) {
                    fresh.push(t.clone());
                }
            }
            updated.tags = fresh;
            updated.modified_at = chrono::Utc::now();
            if let Err(e) = self.store.raw().update_metadata(*id, updated.to_json()) {
                tracing::warn!(target: "inkhaven::tags",
                    "update_metadata({id}) on rename failed: {e}");
                continue;
            }
            touched += 1;
        }
        touched
    }

    /// `T` (or Enter in apply-modes) — apply the selected tag
    /// set (or just the cursor's tag, if nothing is selected) to
    /// the target paragraph(s). On success, close the modal and
    /// return focus to the originating pane.
    fn commit_tags_to_target(&mut self) {
        // 1.2.6+: the `[x]` set IS the intended state for the
        // single-paragraph commit — unchecking a tag now removes
        // it. The picker pre-populates `selected` with the target
        // paragraph's existing tags on open, so the user only
        // pays for what they changed. Multi-paragraph commits
        // remain additive (a destructive bulk-clear is too easy
        // to mis-fire from a multi-mark).
        let (target, mut tags): (TagPickerTarget, Vec<String>) =
            match &self.modal {
                Modal::TagPicker {
                    target, selected, ..
                } => (target.clone(), selected.iter().cloned().collect()),
                _ => return,
            };
        if matches!(target, TagPickerTarget::Search) {
            return;
        }
        tags.sort();
        match &target {
            TagPickerTarget::EditorParagraph { id, title } => {
                // Single paragraph → set-replace semantics. An empty
                // `selected` set means "clear all tags from this
                // paragraph" — the user explicitly chose nothing.
                let ok = self.set_tags_on_node(*id, &tags);
                self.reload_hierarchy();
                self.modal = Modal::None;
                self.change_focus(Focus::Editor);
                self.status = if !ok {
                    format!("tag T: persist failed for `{title}`")
                } else if tags.is_empty() {
                    format!("cleared all tags from `{title}`")
                } else {
                    format!(
                        "set `{title}` tags to: {}",
                        tags.join(", ")
                    )
                };
            }
            TagPickerTarget::TreeSelection(ids) if ids.len() == 1 => {
                let id = ids[0];
                let title = self
                    .hierarchy
                    .get(id)
                    .map(|n| n.title.clone())
                    .unwrap_or_else(|| "<unknown>".into());
                let ok = self.set_tags_on_node(id, &tags);
                self.reload_hierarchy();
                self.modal = Modal::None;
                self.change_focus(Focus::Tree);
                self.status = if !ok {
                    format!("tag T: persist failed for `{title}`")
                } else if tags.is_empty() {
                    format!("cleared all tags from `{title}`")
                } else {
                    format!(
                        "set `{title}` tags to: {}",
                        tags.join(", ")
                    )
                };
            }
            TagPickerTarget::TreeSelection(ids) => {
                // Multi-paragraph → ADD-only. Refuse the no-op
                // (no selection) case loudly so we don't pretend
                // to do something.
                if tags.is_empty() {
                    self.status = "tag T: nothing checked — Space to mark, then T to add".into();
                    return;
                }
                let mut touched = 0usize;
                let mut failed = 0usize;
                for id in ids {
                    if self.add_tags_to_node(*id, &tags) {
                        touched += 1;
                    } else {
                        failed += 1;
                    }
                }
                self.reload_hierarchy();
                self.modal = Modal::None;
                self.change_focus(Focus::Tree);
                self.status = if failed == 0 {
                    format!(
                        "added {} tag(s) to {touched} paragraph(s): {}",
                        tags.len(),
                        tags.join(", ")
                    )
                } else {
                    format!(
                        "tagged {touched}/{} paragraph(s) — {failed} persist failure(s)",
                        ids.len(),
                    )
                };
            }
            TagPickerTarget::Search => {}
        }
    }

    /// Open the backlinks modal for the open paragraph.
    fn open_backlink_picker_modal(&mut self) {
        let Some(doc) = self.opened.as_ref() else {
            self.status = "view K: no paragraph open".into();
            return;
        };
        let target = doc.id;
        let entries = self.collect_backlink_entries(target);
        if entries.is_empty() {
            self.status =
                "view K: no incoming links to this paragraph".into();
            return;
        }
        self.modal = Modal::BacklinkPicker {
            target,
            entries,
            cursor: 0,
            scroll: 0,
        };
        self.status =
            "backlinks: ↑↓ select · Enter opens · D removes source link · Esc closes"
                .into();
    }

    /// Walk every paragraph and collect the ones whose
    /// `linked_paragraphs` contains `target`. O(N) over the
    /// hierarchy — acceptable for typical project sizes; if it
    /// ever bites, the obvious next step is a reverse-index
    /// cached at hierarchy-load time.
    fn collect_backlink_entries(&self, target: Uuid) -> Vec<ScriptPickerEntry> {
        let mut out: Vec<ScriptPickerEntry> = Vec::new();
        for (n, _) in self.hierarchy.flatten() {
            if n.kind != NodeKind::Paragraph {
                continue;
            }
            if !n.linked_paragraphs.contains(&target) {
                continue;
            }
            out.push(ScriptPickerEntry {
                id: n.id,
                title: n.title.clone(),
                slug_path: self.hierarchy.slug_path(n),
            });
        }
        out.sort_by(|a, b| a.slug_path.cmp(&b.slug_path));
        out
    }

    /// Resolve the open paragraph's outgoing links into picker
    /// entries (title + slug path). Stale UUIDs (target deleted)
    /// are silently filtered.
    fn collect_link_entries(&self, owner: Uuid) -> Vec<ScriptPickerEntry> {
        let Some(node) = self.hierarchy.get(owner) else {
            return Vec::new();
        };
        node.linked_paragraphs
            .iter()
            .filter_map(|id| self.hierarchy.get(*id))
            .map(|n| ScriptPickerEntry {
                id: n.id,
                title: n.title.clone(),
                slug_path: self.hierarchy.slug_path(n),
            })
            .collect()
    }

    /// Add `target` to `owner`'s outgoing links. Rejects self-
    /// linking and circular references (walks `target`'s
    /// outgoing closure looking for `owner`). Persists via
    /// `Store::update_metadata`.
    fn add_paragraph_link(
        &mut self,
        owner: Uuid,
        target: Uuid,
    ) -> std::result::Result<(), String> {
        if owner == target {
            return Err("can't link a paragraph to itself".into());
        }
        let owner_node = self
            .hierarchy
            .get(owner)
            .cloned()
            .ok_or_else(|| format!("owner paragraph {owner} not in hierarchy"))?;
        if owner_node.linked_paragraphs.contains(&target) {
            return Err("already linked".into());
        }
        // Circular guard: walk target's outgoing transitive closure
        // depth-first; if owner appears in it, we'd create a loop.
        if self.link_path_exists(target, owner) {
            return Err(
                "You can not create circular references".into(),
            );
        }
        let target_node = self
            .hierarchy
            .get(target)
            .ok_or_else(|| format!("target paragraph {target} not in hierarchy"))?;
        if target_node.kind != NodeKind::Paragraph {
            return Err(format!("`{}` is not a paragraph", target_node.title));
        }
        let mut updated = owner_node.clone();
        updated.linked_paragraphs.push(target);
        updated.modified_at = chrono::Utc::now();
        // 1.2.6+: when `owner` is an event paragraph, the new
        // link drops the `orphan` tag (and fires
        // hook.on_event_linked if/when that exists). Reconcile
        // BEFORE writing so a single update_metadata persists
        // both the link AND the tag transition. Idempotent /
        // no-op on non-event nodes.
        crate::store::reconcile_event_orphan_tag(&mut updated);
        self.store
            .raw()
            .update_metadata(owner, updated.to_json())
            .map_err(|e| format!("store update: {e}"))?;
        self.reload_hierarchy();
        Ok(())
    }

    /// Remove `target` from `owner`'s outgoing links. Returns
    /// `false` when the link wasn't present (no-op).
    fn remove_paragraph_link(
        &mut self,
        owner: Uuid,
        target: Uuid,
    ) -> std::result::Result<bool, String> {
        let owner_node = self
            .hierarchy
            .get(owner)
            .cloned()
            .ok_or_else(|| format!("owner paragraph {owner} not in hierarchy"))?;
        if !owner_node.linked_paragraphs.contains(&target) {
            return Ok(false);
        }
        let mut updated = owner_node.clone();
        updated.linked_paragraphs.retain(|u| *u != target);
        updated.modified_at = chrono::Utc::now();
        // 1.2.6+: when `owner` is an event paragraph, losing
        // its last link flips it back to orphan. Reconcile
        // before writing so the tag re-appears atomically with
        // the link removal. No-op on non-event nodes.
        crate::store::reconcile_event_orphan_tag(&mut updated);
        self.store
            .raw()
            .update_metadata(owner, updated.to_json())
            .map_err(|e| format!("store update: {e}"))?;
        self.reload_hierarchy();
        Ok(true)
    }

    /// True when `start`'s outgoing-link transitive closure
    /// reaches `goal`. Used by `add_paragraph_link` to refuse
    /// cycles. Bounded DFS — a malformed graph with a cycle in
    /// it (shouldn't be possible given this very check, but
    /// stay safe) terminates via `visited`.
    fn link_path_exists(&self, start: Uuid, goal: Uuid) -> bool {
        let mut stack: Vec<Uuid> = vec![start];
        let mut visited: std::collections::HashSet<Uuid> =
            std::collections::HashSet::new();
        while let Some(id) = stack.pop() {
            if id == goal {
                return true;
            }
            if !visited.insert(id) {
                continue;
            }
            if let Some(node) = self.hierarchy.get(id) {
                for next in &node.linked_paragraphs {
                    stack.push(*next);
                }
            }
        }
        false
    }

    /// Dispatch one of the three Ctrl+V markdown-export scopes
    /// through the per-scope `prepare_*` helpers, then open the
    /// SaveMarkdown modal pre-filled with the default
    /// destination. Enter on the modal writes; the user can
    /// edit the path before pressing Enter to redirect.
    fn view_export_markdown(&mut self, scope: ViewMdScope) {
        let prepared = match scope {
            ViewMdScope::Buffer => self.prepare_markdown_buffer(),
            ViewMdScope::Subchapter => self.prepare_markdown_subchapter(),
            ViewMdScope::Subtree => self.prepare_markdown_tree_subtree(),
        };
        match prepared {
            Ok((body, default_dest, label)) => {
                self.open_save_markdown_modal(body, default_dest, label);
            }
            Err(e) => self.status = format!("view: {e}"),
        }
    }

    /// Open the save-as modal with the default path pre-filled.
    /// The user can edit; Enter writes; Esc cancels.
    fn open_save_markdown_modal(
        &mut self,
        body: String,
        default_dest: std::path::PathBuf,
        label: String,
    ) {
        let mut input = TextInput::new();
        for c in default_dest.to_string_lossy().chars() {
            input.insert_char(c);
        }
        self.modal = Modal::SaveMarkdown {
            input,
            body,
            label,
        };
        self.status =
            "save as: edit path or just hit Enter to save · Esc cancels".into();
    }

    /// Commit `body` to whatever path the SaveMarkdown modal's
    /// input contains. Empty input falls back to a fresh default
    /// (defensive — pre-fill should make this rare).
    fn commit_save_markdown(&mut self, body: String, label: String, raw: String) {
        let path_str = raw.trim();
        let path = if path_str.is_empty() {
            match self.default_markdown_dest(&label) {
                Ok(p) => p,
                Err(e) => {
                    self.status = format!("save as: {e}");
                    return;
                }
            }
        } else {
            // Expand `~/` to home if present so users can paste a
            // tilde path. No glob / env expansion — kept minimal.
            let expanded = if let Some(rest) = path_str.strip_prefix("~/") {
                match std::env::var_os("HOME") {
                    Some(home) => std::path::PathBuf::from(home).join(rest),
                    None => std::path::PathBuf::from(path_str),
                }
            } else {
                std::path::PathBuf::from(path_str)
            };
            expanded
        };
        match std::fs::write(&path, body.as_bytes()) {
            Ok(()) => {
                self.status = format!("view: wrote {}", path.display());
            }
            Err(e) => {
                self.status = format!("save as: write {}: {e}", path.display());
            }
        }
    }

    fn handle_view_action(&mut self, key: KeyEvent) {
        self.view_pending = false;
        if matches!(key.code, KeyCode::Esc) {
            self.status = "view cancelled".into();
            return;
        }
        let plain = !key
            .modifiers
            .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER);
        if !plain {
            self.status = "view cancelled".into();
            return;
        }
        let resolved = super::keybind::read().resolve_view_sub(&key, self.focus);
        match resolved {
            Some(super::keybind::Action::None) => {
                self.status = "view: chord disabled by config".into();
            }
            Some(action) => self.run_action(action),
            None => {
                self.status =
                    "view: unknown chord — 1/2 export · S similar · G progress · T target · Esc".into();
            }
        }
    }

    /// Entry point for the Ctrl+V S chord. Behaviour depends on
    /// whether we're already in similar-paragraph mode:
    ///
    /// * Not in mode → save the current buffer, embed it as a
    ///   similarity query against the vector index, and open the
    ///   SimilarPicker modal with the results (current paragraph
    ///   filtered out, paragraphs only).
    /// * Already in mode → save both buffers and drop the
    ///   secondary doc so the layout returns to tree | editor | AI.
    fn toggle_similar_paragraph_mode(&mut self) {
        // 1.2.12+ — similar-mode gates on its own
        // `similar_mode` flag now (was `secondary
        // .is_some()`) so it doesn't bleed into the
        // Shift+F4 split-view's use of the same slot.
        if self.similar_mode {
            // Save both, then exit similar mode. If the focused
            // (= `self.opened`) doc fails to save, surface that
            // first and keep the user in similar mode so they can
            // fix it. The unfocused doc's save error is swallowed
            // with a tracing log — it's the secondary buffer,
            // less critical to surface immediately.
            if let Err(e) = self.save_current() {
                self.status = format!("view S: save primary failed — {e:#}");
                return;
            }
            if let Some(mut sec) = self.secondary.take() {
                if sec.dirty {
                    if let Err(e) = self.save_doc(&mut sec) {
                        tracing::warn!(
                            target: "inkhaven::view_similar",
                            "secondary save failed: {e:#}",
                        );
                        self.status = format!(
                            "view S: exited (secondary save warning: {e:#})"
                        );
                        self.secondary_focused = false;
                        return;
                    }
                }
            }
            self.secondary_focused = false;
            self.similar_mode = false;
            self.status = "view S: exited similar-paragraphs mode".into();
            return;
        }
        // Not in mode — open the picker. Need an open paragraph
        // to derive the similarity query from.
        let Some(doc) = self.opened.as_ref() else {
            self.status = "view S: no paragraph open — nothing to compare against".into();
            return;
        };
        // Save the current buffer first so the similarity search
        // sees on-disk text (the vector index is refreshed on
        // save). If save fails, abort — searching against stale
        // bytes would mislead.
        if doc.dirty {
            if let Err(e) = self.save_current() {
                self.status = format!("view S: save failed — {e:#}");
                return;
            }
        }
        let (current_id, query) = match self.opened.as_ref() {
            Some(d) => (d.id, d.textarea.lines().join("\n")),
            None => {
                self.status = "view S: paragraph closed during save".into();
                return;
            }
        };
        if query.trim().is_empty() {
            self.status = "view S: paragraph is empty — nothing to compare".into();
            return;
        }
        match self.find_similar_paragraphs(current_id, &query, 20) {
            Ok(entries) if entries.is_empty() => {
                self.status =
                    "view S: no similar paragraphs found (need more indexed content)".into();
            }
            Ok(entries) => {
                self.modal = Modal::SimilarPicker {
                    entries,
                    cursor: 0,
                    scroll: 0,
                };
                self.status =
                    "similar: ↑↓ select · Enter open side-by-side · Esc cancel".into();
            }
            Err(e) => {
                self.status = format!("view S: search failed — {e:#}");
            }
        }
    }

    /// Run a vector-similarity search seeded with `query` and
    /// turn the raw hits into picker entries. Filters out
    /// `exclude_id` (the current paragraph; would otherwise top
    /// the list with score = 1.0) and any non-Paragraph kind
    /// (Help-book content, Notes/Places/etc. should surface
    /// elsewhere — the user asked for paragraphs).
    fn find_similar_paragraphs(
        &self,
        exclude_id: Uuid,
        query: &str,
        limit: usize,
    ) -> anyhow::Result<Vec<SimilarPickerEntry>> {
        use crate::tui::search_results::SearchHit;
        // Over-fetch: the dedup pass inside the store collapses
        // meta/content slots, but our own filters (current id,
        // non-paragraph kinds) still drop rows. Aim for `limit`
        // *survivors*, not `limit` raw hits.
        let raw = self
            .store
            .search_text(query, (limit + 4).max(8))
            .map_err(|e| anyhow::anyhow!("similarity search: {e}"))?;
        let mut out: Vec<SimilarPickerEntry> = Vec::new();
        for v in raw.iter() {
            let Some(hit) = SearchHit::parse(v) else {
                continue;
            };
            if hit.id == exclude_id {
                continue;
            }
            if !matches!(hit.kind, crate::store::node::NodeKind::Paragraph) {
                continue;
            }
            // Only surface paragraphs that still live in the
            // hierarchy (the vector index can lag a fast delete).
            let Some(node) = self.hierarchy.get(hit.id) else {
                continue;
            };
            let slug_path = self.hierarchy.slug_path(node);
            out.push(SimilarPickerEntry {
                id: hit.id,
                title: hit.title.clone(),
                slug_path,
                score: hit.score,
                snippet: hit.snippet.clone(),
            });
            if out.len() >= limit {
                break;
            }
        }
        Ok(out)
    }

    // ── FF.1 — Facts semantic-search modal (1.2.21+) ──────────

    /// `Ctrl+B Shift+S` — open the Facts search modal.
    fn open_facts_search(&mut self) {
        if self.system_book_id(crate::store::SYSTEM_TAG_FACTS).is_none() {
            self.status = "facts search: this project has no Facts book".into();
            return;
        }
        self.modal = Modal::FactsSearch {
            input: crate::tui::input::TextInput::new(),
            entries: Vec::new(),
            cursor: 0,
            marked: std::collections::BTreeSet::new(),
            browsing: false,
        };
        self.status =
            "facts search: type a query, Enter searches · Esc cancels".into();
    }

    /// Semantic search over the Facts book for `query`, ranked, capped
    /// at `limit`.  Whole-project `search_text` post-filtered to the
    /// Facts subtree (the vector index has no per-book scope), reusing
    /// the same call the similar-paragraph picker uses but seeded from a
    /// typed query instead of a paragraph.
    fn search_facts(&self, query: &str, limit: usize) -> Vec<SimilarPickerEntry> {
        use crate::tui::search_results::SearchHit;
        let Some(facts_id) = self.system_book_id(crate::store::SYSTEM_TAG_FACTS) else {
            return Vec::new();
        };
        let facts_ids: std::collections::HashSet<Uuid> = self
            .hierarchy
            .collect_subtree(facts_id)
            .into_iter()
            .collect();
        let raw = match self.store.search_text(query, (limit + 8).max(16)) {
            Ok(r) => r,
            Err(_) => return Vec::new(),
        };
        let mut out: Vec<SimilarPickerEntry> = Vec::new();
        for v in raw.iter() {
            let Some(hit) = SearchHit::parse(v) else {
                continue;
            };
            if !facts_ids.contains(&hit.id) {
                continue;
            }
            if !matches!(hit.kind, crate::store::node::NodeKind::Paragraph) {
                continue;
            }
            let Some(node) = self.hierarchy.get(hit.id) else {
                continue;
            };
            let slug_path = self.hierarchy.slug_path(node);
            out.push(SimilarPickerEntry {
                id: hit.id,
                title: hit.title.clone(),
                slug_path,
                score: hit.score,
                snippet: hit.snippet.clone(),
            });
            if out.len() >= limit {
                break;
            }
        }
        out
    }

    /// Run the search from the modal's current query, populate the
    /// results, and switch to browsing mode when there are any.
    fn run_facts_search(&mut self) {
        let query = if let Modal::FactsSearch { input, .. } = &self.modal {
            input.as_str().trim().to_string()
        } else {
            return;
        };
        if query.is_empty() {
            self.status = "facts search: type a query first".into();
            return;
        }
        let entries = self.search_facts(&query, 25);
        let n = entries.len();
        if let Modal::FactsSearch {
            entries: e,
            cursor,
            browsing,
            ..
        } = &mut self.modal
        {
            *e = entries;
            *cursor = 0;
            *browsing = n > 0;
        }
        self.status = if n == 0 {
            format!("facts search: no matches for “{query}”")
        } else {
            format!(
                "facts search: {n} match{} · ↑↓ move · Space mark · Enter send",
                if n == 1 { "" } else { "es" },
            )
        };
    }

    /// Send the marked facts (or the cursor's row if none marked) to a
    /// targeted Facts chat: seed the chat with just those facts + the
    /// fact-analysis framing (`apply_facts_seed`), then drop on the AI
    /// prompt.  The scalable path for a big Facts book — grounding in
    /// the relevant handful rather than the whole book.
    fn facts_search_send(&mut self) {
        let ids: Vec<Uuid> = if let Modal::FactsSearch {
            entries,
            cursor,
            marked,
            ..
        } = &self.modal
        {
            if !marked.is_empty() {
                marked.iter().copied().collect()
            } else if let Some(e) = entries.get(*cursor) {
                vec![e.id]
            } else {
                Vec::new()
            }
        } else {
            return;
        };
        if ids.is_empty() {
            self.status = "facts search: nothing to send".into();
            return;
        }
        let chunks = self.facts_chunks_for(&ids);
        if chunks.is_empty() {
            self.status = "facts search: the selected facts are empty".into();
            return;
        }
        let n = self.apply_facts_seed(chunks);
        self.modal = Modal::None;
        self.change_focus(Focus::AiPrompt);
        let plural = if n == 1 { "y" } else { "ies" };
        self.status = format!(
            "Facts chat: grounded in {n} matched fact entr{plural} — ask away (F9 to exit)"
        );
    }

    /// Key handler for the two-phase Facts search modal.  Query mode:
    /// Enter searches, printable chars (incl. Space) edit the query.
    /// Results mode: ↑↓ move, Space marks (multi-select), Enter sends,
    /// any printable / Backspace drops back to refine the query.  Esc
    /// closes from either mode.
    fn facts_search_handle_key(&mut self, key: crossterm::event::KeyEvent) {
        use crossterm::event::{KeyCode, KeyModifiers};
        if matches!(key.code, KeyCode::Esc) {
            self.modal = Modal::None;
            self.status = "facts search: closed".into();
            return;
        }
        let browsing = matches!(self.modal, Modal::FactsSearch { browsing: true, .. });
        if !browsing {
            match key.code {
                KeyCode::Enter => self.run_facts_search(),
                KeyCode::Backspace => {
                    if let Modal::FactsSearch { input, .. } = &mut self.modal {
                        input.backspace();
                    }
                }
                KeyCode::Char(c)
                    if !key.modifiers.contains(KeyModifiers::CONTROL)
                        && !key.modifiers.contains(KeyModifiers::ALT) =>
                {
                    if let Modal::FactsSearch { input, .. } = &mut self.modal {
                        input.insert_char(c);
                    }
                }
                _ => {}
            }
            return;
        }
        // Results mode.
        match key.code {
            KeyCode::Up => {
                if let Modal::FactsSearch { cursor, .. } = &mut self.modal {
                    *cursor = cursor.saturating_sub(1);
                }
            }
            KeyCode::Down => {
                if let Modal::FactsSearch { cursor, entries, .. } = &mut self.modal {
                    if !entries.is_empty() && *cursor + 1 < entries.len() {
                        *cursor += 1;
                    }
                }
            }
            // Space marks — must precede the generic Char arm so a space
            // toggles instead of dropping back to the query.
            KeyCode::Char(' ') => {
                if let Modal::FactsSearch {
                    entries,
                    cursor,
                    marked,
                    ..
                } = &mut self.modal
                {
                    if let Some(e) = entries.get(*cursor) {
                        if marked.contains(&e.id) {
                            marked.remove(&e.id);
                        } else {
                            marked.insert(e.id);
                        }
                    }
                }
            }
            KeyCode::Enter => self.facts_search_send(),
            KeyCode::Char(c)
                if !key.modifiers.contains(KeyModifiers::CONTROL)
                    && !key.modifiers.contains(KeyModifiers::ALT) =>
            {
                if let Modal::FactsSearch { input, browsing, .. } = &mut self.modal {
                    *browsing = false;
                    input.insert_char(c);
                }
            }
            KeyCode::Backspace => {
                if let Modal::FactsSearch { input, browsing, .. } = &mut self.modal {
                    *browsing = false;
                    input.backspace();
                }
            }
            _ => {}
        }
    }

    /// Compute (markdown body, default destination, status-bar
    /// label) for the open paragraph's buffer. Used by the
    /// save-as flow — `view_export_markdown` opens the
    /// `SaveMarkdown` modal pre-filled with `default_dest`.
    fn prepare_markdown_buffer(
        &self,
    ) -> std::result::Result<(String, std::path::PathBuf, String), String> {
        let doc = self
            .opened
            .as_ref()
            .ok_or_else(|| "no paragraph open".to_string())?;
        let typst_src = doc.textarea.lines().join("\n");
        let md = crate::export::markdown::typst_to_markdown(&typst_src);
        let dest = self.default_markdown_dest(&doc.title)?;
        Ok((md, dest, doc.title.clone()))
    }

    fn prepare_markdown_subchapter(
        &self,
    ) -> std::result::Result<(String, std::path::PathBuf, String), String> {
        let doc = self
            .opened
            .as_ref()
            .ok_or_else(|| "no paragraph open".to_string())?;
        let para = self
            .hierarchy
            .get(doc.id)
            .ok_or_else(|| "paragraph not in hierarchy".to_string())?;
        let root = self
            .hierarchy
            .ancestors(para)
            .into_iter()
            .find(|a| {
                matches!(
                    a.kind,
                    crate::store::node::NodeKind::Subchapter
                        | crate::store::node::NodeKind::Chapter
                )
            })
            .ok_or_else(|| "no containing subchapter".to_string())?;
        self.prepare_markdown_subtree_of(root)
    }

    fn prepare_markdown_tree_subtree(
        &self,
    ) -> std::result::Result<(String, std::path::PathBuf, String), String> {
        let (id, _) = *self
            .rows
            .get(self.tree_cursor)
            .ok_or_else(|| "no tree row under cursor".to_string())?;
        let node = self
            .hierarchy
            .get(id)
            .ok_or_else(|| "node missing from hierarchy".to_string())?;
        self.prepare_markdown_subtree_of(node)
    }

    fn prepare_markdown_subtree_of(
        &self,
        root: &crate::store::node::Node,
    ) -> std::result::Result<(String, std::path::PathBuf, String), String> {
        let layout = crate::project::ProjectLayout::new(self.store.project_root());
        let combined = crate::export::assemble_typst_source(
            &layout,
            &self.hierarchy,
            Some(root.id),
        )
        .map_err(|e| format!("assemble: {e:#}"))?;
        let md = crate::export::markdown::typst_to_markdown(&combined);
        let dest = self.default_markdown_dest(&root.title)?;
        Ok((md, dest, root.title.clone()))
    }

    /// Compute the default markdown destination for a given
    /// title. Format: `<cwd>/<slug>-YYYYDDMM-HHMM.md`. Same
    /// scheme `write_markdown_to_cwd` used before 1.2.4's
    /// save-as picker.
    fn default_markdown_dest(
        &self,
        title: &str,
    ) -> std::result::Result<std::path::PathBuf, String> {
        let cwd = std::env::current_dir().map_err(|e| format!("cwd: {e}"))?;
        let stamp = chrono::Local::now().format("%Y%d%m-%H%M");
        let stem = slug::slugify(title);
        let safe_stem = if stem.is_empty() { "buffer".to_string() } else { stem };
        Ok(cwd.join(format!("{safe_stem}-{stamp}.md")))
    }

    /// 1.2.6+ — change the live preview zoom by `delta` (units
    /// of PPI). Clamps to [0.5, 6.0]. Re-renders every page at
    /// the new PPI; current_page is preserved so the user stays
    /// on the page they were inspecting. Failures (typst
    /// compile error after edit) leave the existing pages in
    /// place + drop a status line.
    fn zoom_rendered_preview(&mut self, delta: f32) {
        let Modal::RenderedPreview {
            body,
            settings,
            ppi,
            current_page,
            title,
            ..
        } = &self.modal
        else {
            return;
        };
        let body = body.clone();
        let settings = settings.clone();
        let cur_page = *current_page;
        let title = title.clone();
        let new_ppi = (*ppi + delta).clamp(0.5, 6.0);
        if (new_ppi - *ppi).abs() < f32::EPSILON {
            self.status = format!(
                "render ¶: zoom at limit ({:.1}x)",
                *ppi,
            );
            return;
        }
        let Some(picker) = self.image_picker.as_ref() else {
            return;
        };
        match crate::typst_paragraph_render::render_all(
            &body,
            settings.clone(),
            new_ppi,
        ) {
            Ok(rendered) => {
                let pages: Vec<RenderedPageProto> = rendered
                    .into_iter()
                    .map(|r| RenderedPageProto {
                        proto: picker.new_resize_protocol(r.image),
                        width: r.width,
                        height: r.height,
                    })
                    .collect();
                if pages.is_empty() {
                    self.status =
                        "render ¶ zoom: empty render — keeping previous pages".into();
                    return;
                }
                let new_cur = cur_page.min(pages.len() - 1);
                let p = &pages[new_cur];
                let stamp = format!(
                    "render ¶ `{title}` · zoom {:.1}x · page {}/{} · {}×{}",
                    new_ppi,
                    new_cur + 1,
                    pages.len(),
                    p.width,
                    p.height,
                );
                if let Modal::RenderedPreview {
                    pages: dst_pages,
                    current_page: dst_cur,
                    ppi: dst_ppi,
                    ..
                } = &mut self.modal
                {
                    *dst_pages = pages;
                    *dst_cur = new_cur;
                    *dst_ppi = new_ppi;
                }
                self.status = stamp;
            }
            Err(e) => {
                let first = e
                    .lines()
                    .find(|l| !l.trim().is_empty())
                    .unwrap_or("zoom render failed");
                self.status = format!("render ¶ zoom: {first}");
            }
        }
    }

    /// 1.2.5+ — default destination for `S` on Modal::RenderedPreview.
    /// Mirrors the markdown dest shape but with `.png` extension.
    fn default_rendered_png_dest(
        &self,
        title: &str,
    ) -> std::result::Result<std::path::PathBuf, String> {
        let cwd = std::env::current_dir().map_err(|e| format!("cwd: {e}"))?;
        let stamp = chrono::Local::now().format("%Y%d%m-%H%M");
        let stem = slug::slugify(title);
        let safe_stem = if stem.is_empty() { "render".to_string() } else { stem };
        Ok(cwd.join(format!("{safe_stem}-{stamp}.png")))
    }

    /// `S` (single) or `A` (all) inside Modal::RenderedPreview —
    /// pop the save-as picker with a sensible default path
    /// pre-filled. `all = true` stamps a multi-page-aware default
    /// (still a single base path; we append `-page-NNN` per page
    /// at write time). The picker stashes the underlying preview
    /// modal in `return_to` so Esc preserves navigation state.
    fn open_save_rendered_png_picker(&mut self, all: bool) {
        let (body, settings, title, current_page) = match &self.modal {
            Modal::RenderedPreview {
                body,
                settings,
                title,
                current_page,
                ..
            } => (
                body.clone(),
                settings.clone(),
                title.clone(),
                *current_page,
            ),
            _ => return,
        };
        let default_dest = match self.default_rendered_png_dest(&title) {
            Ok(p) => p,
            Err(e) => {
                self.status = format!("save PNG as: {e}");
                return;
            }
        };
        let mut input = TextInput::new();
        for c in default_dest.to_string_lossy().chars() {
            input.insert_char(c);
        }
        // Move the current modal into the picker's return_to
        // stash — std::mem::replace avoids cloning the protos.
        let return_to = Box::new(std::mem::replace(&mut self.modal, Modal::None));
        let pages = if all {
            PagesToSave::All
        } else {
            PagesToSave::Single(current_page)
        };
        let mode_label = match &pages {
            PagesToSave::Single(idx) => format!("page {}", idx + 1),
            PagesToSave::All => "all pages".to_string(),
        };
        self.modal = Modal::SaveRenderedPng {
            input,
            body,
            settings,
            title,
            pages,
            return_to,
        };
        self.status = format!(
            "save PNG as ({mode_label}): edit path or hit Enter · Esc returns to preview",
        );
    }

    /// Re-render the paragraph at full DPI (4.0 px/pt) and write
    /// to the picked path. Status-bar reports outcome. For
    /// `PagesToSave::All` we strip a trailing `.png` from the
    /// user's input (if present) and append `-page-NNN.png` per
    /// page; for `Single(idx)` the input is used verbatim.
    fn commit_save_rendered_png(
        &mut self,
        body: &str,
        settings: &crate::typst_world::WorldSettings,
        raw: &str,
        title: &str,
        pages: PagesToSave,
    ) {
        let path_str = raw.trim();
        let base_path = if path_str.is_empty() {
            match self.default_rendered_png_dest(title) {
                Ok(p) => p,
                Err(e) => {
                    self.status = format!("save PNG: {e}");
                    return;
                }
            }
        } else if let Some(rest) = path_str.strip_prefix("~/") {
            match std::env::var_os("HOME") {
                Some(home) => std::path::PathBuf::from(home).join(rest),
                None => std::path::PathBuf::from(path_str),
            }
        } else {
            std::path::PathBuf::from(path_str)
        };
        // 4.0 px/pt ≈ 288 dpi. Print-quality without going
        // wild on memory for chapter-sized paragraphs.
        match pages {
            PagesToSave::Single(idx) => match crate::typst_paragraph_render::render_page(
                body,
                settings.clone(),
                4.0,
                idx,
            ) {
                Ok(rendered) => match std::fs::write(&base_path, &rendered.png_bytes) {
                    Ok(()) => {
                        self.status = format!(
                            "save PNG: wrote {} (page {} · {}×{} · {} bytes)",
                            base_path.display(),
                            idx + 1,
                            rendered.width,
                            rendered.height,
                            rendered.png_bytes.len(),
                        );
                    }
                    Err(e) => {
                        self.status =
                            format!("save PNG: write {}: {e}", base_path.display());
                    }
                },
                Err(e) => {
                    let first =
                        e.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
                    self.status =
                        format!("save PNG: re-render failed: {first}");
                }
            },
            PagesToSave::All => {
                // Strip a trailing .png so `myrender.png` and
                // `myrender` both become `myrender-page-001.png` etc.
                let stem = base_path
                    .file_stem()
                    .map(|s| s.to_string_lossy().into_owned())
                    .unwrap_or_else(|| "render".to_string());
                let parent = base_path.parent().map(|p| p.to_path_buf()).unwrap_or_default();
                match crate::typst_paragraph_render::render_all(
                    body,
                    settings.clone(),
                    4.0,
                ) {
                    Ok(rendered_pages) => {
                        let total = rendered_pages.len();
                        let pad = total.to_string().len().max(3);
                        let mut written: Vec<String> = Vec::with_capacity(total);
                        for (i, page) in rendered_pages.iter().enumerate() {
                            let fname =
                                format!("{stem}-page-{:0pad$}.png", i + 1, pad = pad);
                            let dest = parent.join(&fname);
                            if let Err(e) = std::fs::write(&dest, &page.png_bytes) {
                                self.status = format!(
                                    "save PNG: write {} failed: {e} (wrote {} of {})",
                                    dest.display(),
                                    written.len(),
                                    total,
                                );
                                return;
                            }
                            written.push(fname);
                        }
                        let in_dir = if parent.as_os_str().is_empty() {
                            "(cwd)".to_string()
                        } else {
                            parent.display().to_string()
                        };
                        self.status = format!(
                            "save PNG: wrote {} pages to {} ({})",
                            total,
                            in_dir,
                            // Show first..last filename for context
                            // — full list would blow the status bar.
                            if total == 1 {
                                written[0].clone()
                            } else {
                                format!("{}…{}", written[0], written[total - 1])
                            },
                        );
                    }
                    Err(e) => {
                        let first =
                            e.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
                        self.status =
                            format!("save PNG (all): re-render failed: {first}");
                    }
                }
            }
        }
    }

    fn handle_bund_action(&mut self, key: KeyEvent) {
        self.bund_pending = false;
        if matches!(key.code, KeyCode::Esc) {
            self.status = "bund cancelled".into();
            return;
        }
        let plain = !key
            .modifiers
            .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER);
        if !plain {
            self.status = "bund cancelled".into();
            return;
        }
        let resolved = super::keybind::read().resolve_bund_sub(&key, self.focus);
        match resolved {
            Some(super::keybind::Action::None) => {
                self.status = "bund: chord disabled by config".into();
            }
            Some(action) => self.run_action(action),
            None => {
                self.status =
                    "bund: unknown action — R run · N new · E eval · Esc cancel".into();
            }
        }
    }

    /// Central dispatcher for every chord-action. Stage 1 of the
    /// rebindable-keys roadmap (`Documentation/RELEASE_NOTES/…`):
    /// the table at `self.keys` resolves `KeyEvent` → `Action`,
    /// and this switch is the single point where each variant
    /// hits its concrete handler. Adding a new action means a
    /// new variant in `keybind::Action`, a new arm here, and an
    /// entry in `KeyBindings::defaults()`.
    fn run_action(&mut self, action: super::keybind::Action) {
        use super::keybind::Action as A;
        // 1.2.15+ Phase R.1 — push the action name
        // onto the crash-report recent-action ring so
        // a post-mortem reader can reconstruct what
        // the user was doing.  Uses the Debug
        // formatting since Action has no Display
        // impl; the discriminant name is what we
        // care about.
        crate::crash::context().push_action(
            crate::crash::ActionRecord::new(format!("action.{action:?}")),
        );
        match action {
            // ── Tree pane ─────────────────────────────────────
            A::AddBook => self.open_add_modal(NodeKind::Book),
            A::AddChapter => self.open_add_modal(NodeKind::Chapter),
            A::AddSubchapter => self.open_add_modal(NodeKind::Subchapter),
            A::AddParagraph => self.open_add_modal(NodeKind::Paragraph),
            A::DeleteNode => self.open_delete_modal(),
            A::MorphType => self.cycle_leaf_type(),
            A::ReorderUp => self.move_current(MoveDir::Up),
            A::ReorderDown => self.move_current(MoveDir::Down),

            // ── Editor pane ───────────────────────────────────
            A::Save => {
                if self.opened.is_some() {
                    let _ = self.save_current();
                } else {
                    self.status = "no paragraph open".into();
                }
            }
            A::CreateSnapshot => self.create_snapshot_of_current(),
            A::CycleStatus => self.cycle_paragraph_status(),
            A::OpenFunctionPicker => self.open_function_picker(),
            A::RenameToFirstSentence => self.rename_paragraph_to_first_sentence(),
            A::LookupPlacesOrImage => {
                if !self.try_open_image_picker() {
                    self.start_lexicon_inference(LexiconKind::Places);
                }
            }
            A::LookupCharacters => self.start_lexicon_inference(LexiconKind::Characters),
            A::LookupNotes => self.start_lexicon_inference(LexiconKind::Notes),
            A::LookupArtefacts => self.start_lexicon_inference(LexiconKind::Artefacts),
            A::OpenQuickref => self.open_quickref(),

            // ── Global ────────────────────────────────────────
            A::OpenCredits => self.open_credits(),
            A::OpenBookInfo => self.open_book_info(),
            A::OpenImpositionPreview => self.open_imposition_preview(),
            A::OpenSubmissionsTracker => self.open_submissions_tracker(),
            A::OpenSubmissionGen => self.open_submission_gen(),
            A::OpenPlanOutline => self.open_plan_outline(),
            A::OpenEditorialPass => self.open_editorial_pass(),
            A::OpenStoryBible => self.open_story_bible(),
            A::RunDeepRefresh => self.start_deep_refresh(),
            A::OpenLlmPicker => self.open_llm_picker(),
            A::ToggleSound => self.toggle_sound(),
            A::ToggleMouseCapture => self.toggle_mouse_capture(),
            A::VisitedBack => self.navigate_visited_back(),
            A::VisitedForward => self.navigate_visited_forward(),
            A::UndoLastDelete => self.undo_last_delete(),
            A::ScheduleAssemble => self.schedule_assembly(),
            A::ScheduleBuild => self.schedule_build(),
            A::ScheduleTake => self.schedule_take(),
            A::BackupNow => self.schedule_backup_now(),
            A::ToggleTypewriter => self.toggle_typewriter_mode(),
            A::ToggleAiFullscreen => self.toggle_ai_fullscreen(),
            A::StatusFilterReady => self.open_status_filter("Ready"),
            A::StatusFilterFinal => self.open_status_filter("Final"),
            A::StatusFilterThird => self.open_status_filter("Third"),
            A::StatusFilterSecond => self.open_status_filter("Second"),
            A::StatusFilterFirst => self.open_status_filter("First"),
            A::StatusFilterNapkin => self.open_status_filter("Napkin"),
            A::StatusFilterNone => self.open_status_filter("None"),

            // ── Tagging (1.2.5+) ──────────────────────────────
            A::TagParagraph => self.open_tag_picker_for_editor(),
            A::TagSearch => self.open_tag_search_picker(),

            // ── AI pane ───────────────────────────────────────
            A::ClearChat => self.clear_chat_history(),

            // ── Bund prefix ───────────────────────────────────
            A::BundRunBuffer => self.bund_run_buffer(),
            A::BundNewScript => self.bund_new_script(),
            A::BundOpenEvalModal => self.bund_open_eval_modal(),
            A::BundOpenScriptPicker => self.bund_open_script_picker(),
            A::BundOpenShell => self.open_shell_pane(false),
            A::BundOpenShellFresh => self.open_shell_pane(true),
            A::BundShellSelection => self.toggle_shell_selection_mode(),
            A::BundEditProjectHjson => self.open_hjson_editor(),
            A::TtsReadParagraph => self.tts_read_paragraph(),
            A::TtsSaveAsAudio => self.tts_open_save_as_audio_picker(),
            A::OpenWritingStreakHeatmap => self.open_writing_streak_heatmap(),
            A::OpenDoctorPanel => self.open_doctor_panel(),
            A::OpenJournal => self.open_journal(),
            A::OpenTtsVoicePicker => self.open_tts_voice_picker(),
            A::OpenReaderPace => self.open_reader_pace(),
            A::SceneBreakPrev => self.scene_break_jump(-1),
            A::SceneBreakNext => self.scene_break_jump(1),
            A::ToggleStyleWarnings => self.toggle_style_warnings(),
            A::ToggleEchoOverlay => self.toggle_echo_overlay(),
            A::OpenConcordance => self.open_concordance(),
            A::TogglePovChip => self.toggle_pov_chip(),
            A::TogglePromptLanguageMode => self.toggle_prompt_language_mode(),
            A::OpenSentenceRhythm => self.open_sentence_rhythm(),
            A::AnalyseShowDontTell => self.start_show_dont_tell_scan(),
            A::FactCheck => self.start_fact_check(),
            A::SearchFacts => self.open_facts_search(),
            A::NextFactFinding => self.next_fact_finding(),
            A::AiRewriteRhythm => self.start_sentence_rhythm_rewrite(),
            A::TranslateToInvented => self.start_translate_to_invented(),
            A::TranslateFromInvented => self.start_translate_from_invented(),

            // ── View prefix ───────────────────────────────────
            A::ViewExportMarkdownBuffer => self.view_export_markdown(ViewMdScope::Buffer),
            A::ViewExportMarkdownSubchapter => self.view_export_markdown(ViewMdScope::Subchapter),
            A::ViewExportMarkdownSubtree => self.view_export_markdown(ViewMdScope::Subtree),
            A::ViewToggleSimilarMode => self.toggle_similar_paragraph_mode(),
            A::ViewOpenProgress => self.open_progress_modal(),
            A::ViewOpenParagraphTarget => self.open_paragraph_target_modal(),
            A::ViewAddLink => self.enter_link_pick_mode(),
            A::ViewAddIncomingLink => self.enter_incoming_link_pick_mode(),
            A::ViewListLinks => self.open_link_picker_modal(),
            A::ViewListBacklinks => self.open_backlink_picker_modal(),
            A::ViewToggleBookmark => self.toggle_bookmark(),
            A::ViewListBookmarks => self.open_bookmark_picker_modal(),
            A::ViewFuzzyParagraphPicker => self.open_fuzzy_paragraph_picker(),
            A::ViewRecentParagraphPicker => self.open_recent_paragraph_picker(),
            A::ViewKillRingPicker => self.open_kill_ring_picker(),
            A::ViewHiddenCharsReport => self.report_hidden_chars(),
            A::ViewShowBreadcrumb => self.show_cursor_breadcrumb(),
            A::ViewRenderParagraph => self.open_rendered_paragraph_preview(),
            A::ViewNextDiagnostic => self.jump_to_next_diagnostic(),
            A::ViewStoryGraph => self.open_story_view(),
            A::ViewStoryGraphParagraph => self.open_story_view_paragraph(),
            A::ViewEventPicker => self.open_event_picker(),
            A::ViewTimeline => self.open_timeline_view(),
            A::ViewNewEventPrompt => self.open_new_event_prompt_from_anywhere(),
            A::ViewEditEventMetadata => self.open_edit_event_metadata_prompt(),

            // ── Top-level F-keys (1.2.4+ migration) ───────────
            A::HelpQuery => self.open_help_query_modal(),
            A::RenameNode => self.open_rename_modal(),
            A::FilePickerTreeImport => {
                self.open_file_picker(PickerContext::TreeInsertOrImport)
            }
            A::FilePickerEditorLoad => {
                self.open_file_picker(PickerContext::EditorLoad)
            }
            A::ToggleSplit => self.toggle_split(),
            A::AcceptSplitSnapshot => self.accept_split_snapshot(),
            A::ToggleSplitView => self.toggle_split_view(),
            A::ViewSiblingBookLookup => self.sibling_book_lookup(),
            A::ViewThreadsPicker => self.open_threads_picker(),
            A::AiThreadAudit => self.start_thread_audit(),
            A::ViewThreadDoctor => self.open_thread_doctor(),
            A::ViewAddComment => self.start_add_comment(),
            A::ViewCommentsPanel => self.open_comments_panel(),
            A::AiContinuationDraft => self.start_continuation_draft(),
            A::EditorInsertFootnote => self.start_insert_footnote(),
            A::ViewProjectGoalModal => self.open_project_goal_modal(),
            A::AiStyleTransferRewrite => self.start_style_transfer_picker(),
            A::OpenSnapshotPicker => self.open_snapshot_picker(),
            A::GrammarCheck => self.start_grammar_check(),
            A::CycleAiMode => self.cycle_ai_mode(),
            A::ToggleInferenceMode => self.toggle_inference_mode(),
            A::DiagnosticsList => self.open_diagnostics_list(),
            A::ExplainDiagnostic => self.start_explain_diagnostic(),
            A::Critique => self.start_critique(),

            // Runtime-bound Bund lambda. Dispatch through the
            // hooks machinery so the recursion-cap + policy-deny
            // semantics already in place apply uniformly. No args
            // pushed; the lambda body sees an empty workbench.
            A::BundLambda(name) => {
                crate::scripting::hooks::fire(name.as_ref(), Vec::new());
            }

            // Explicit "do nothing" — should never reach here
            // because the dispatcher catches it first, but harmless.
            A::None => {}
        }
    }

    /// Ctrl+B T — cycle the selected leaf node's flavour through
    /// `Paragraph(typst) → Paragraph(hjson) → Script(bund)` and
    /// back. Target picked from:
    ///   * the open buffer when the focus is on the editor, or
    ///   * the tree cursor otherwise.
    /// Closes + reopens the buffer (if open on the converted node)
    /// so the new highlighter + content_type take effect immediately.
    /// 1.2.4+: multi-select-aware bulk type cycle. When
    /// `tree_marked` is non-empty, runs `cycle_leaf_type_single`
    /// over every marked id and reports the aggregate.
    fn cycle_leaf_type_bulk(&mut self) {
        if self.tree_marked.is_empty() {
            return;
        }
        let ids: Vec<Uuid> = self.tree_marked.iter().copied().collect();
        let mut ok = 0usize;
        let mut fail = 0usize;
        // If the open buffer is in the set, close it first —
        // its rel_path will change as part of the conversion,
        // so reopening from the fresh hierarchy is safer than
        // trying to keep the live doc in sync mid-loop.
        let reopen_id = self
            .opened
            .as_ref()
            .map(|d| d.id)
            .filter(|id| self.tree_marked.contains(id));
        if reopen_id.is_some() {
            self.opened = None;
        }
        for id in &ids {
            if self.cycle_leaf_type_single(*id).is_ok() {
                ok += 1;
            } else {
                fail += 1;
            }
        }
        self.reload_hierarchy();
        // Reopen if needed.
        if let Some(id) = reopen_id {
            if let Some(node) = self.hierarchy.get(id).cloned() {
                if matches!(node.kind, NodeKind::Paragraph) {
                    let _ = self.load_paragraph(&node);
                }
            }
        }
        self.status = if fail == 0 {
            format!("type cycled on {ok} paragraph(s)")
        } else {
            format!("type cycled on {ok} · {fail} failed")
        };
    }

    /// Single-node type cycle used by both the cursor-row chord
    /// and the multi-select wrapper. No buffer-reopen logic —
    /// callers are responsible for that.
    fn cycle_leaf_type_single(
        &mut self,
        node_id: Uuid,
    ) -> std::result::Result<(), String> {
        let node = self
            .hierarchy
            .get(node_id)
            .cloned()
            .ok_or_else(|| format!("node {node_id} not in hierarchy"))?;
        let (new_kind, new_ct, _label) = match (node.kind, node.content_type.as_deref()) {
            (NodeKind::Paragraph, None | Some("typst")) => {
                (NodeKind::Paragraph, Some("hjson"), "hjson")
            }
            (NodeKind::Paragraph, Some("hjson")) => {
                (NodeKind::Script, Some("bund"), "bund")
            }
            (NodeKind::Script, _) => (NodeKind::Paragraph, None, "typst"),
            _ => return Err("not a text leaf".into()),
        };
        self.store
            .convert_leaf(&self.hierarchy, node_id, new_kind, new_ct)
            .map_err(|e| format!("convert: {e}"))?;
        Ok(())
    }

    fn cycle_leaf_type(&mut self) {
        // Pick the node to convert. From the Editor pane: prefer
        // the open buffer; fall back to the tree cursor when the
        // editor pane has nothing open (so M still does the
        // right thing if the user pressed it from a blank editor).
        // From any other pane: always tree cursor.
        let cursor_id = self.rows.get(self.tree_cursor).map(|(id, _)| *id);
        let target_id: Option<Uuid> = match self.focus {
            Focus::Editor => self.opened.as_ref().map(|d| d.id).or(cursor_id),
            _ => cursor_id,
        };
        let Some(node_id) = target_id else {
            self.status = "type-cycle: nothing selected".into();
            return;
        };
        let Some(node) = self.hierarchy.get(node_id).cloned() else {
            self.status = "type-cycle: node missing from hierarchy".into();
            return;
        };
        let (new_kind, new_ct, label) = match (node.kind, node.content_type.as_deref()) {
            (NodeKind::Paragraph, None | Some("typst")) => {
                (NodeKind::Paragraph, Some("hjson"), "hjson")
            }
            (NodeKind::Paragraph, Some("hjson")) => {
                (NodeKind::Script, Some("bund"), "bund")
            }
            (NodeKind::Script, _) => (NodeKind::Paragraph, None, "typst"),
            (k, ct) => {
                self.status = format!(
                    "type-cycle: {} ({ct:?}) is not a text leaf — only paragraphs / scripts cycle",
                    k.as_str()
                );
                return;
            }
        };

        // Snapshot whether the buffer is open on this node + the
        // focus we should be on when we return. `load_paragraph`
        // unconditionally focuses Editor at the end, so if the
        // user invoked the cycle from the Tree pane we'd steal
        // their focus otherwise.
        let buffer_was_open = self.opened.as_ref().is_some_and(|d| d.id == node_id);
        let saved_focus = self.focus;
        if buffer_was_open {
            self.opened = None;
        }

        match self
            .store
            .convert_leaf(&self.hierarchy, node_id, new_kind, new_ct)
        {
            Ok(converted) => {
                self.status =
                    format!("type-cycle: `{}` is now {label}", converted.title);
                self.reload_hierarchy();
                if buffer_was_open {
                    let _ = self.load_paragraph(&converted);
                    // `load_paragraph` focuses the editor; restore
                    // the pane the user was actually in.
                    if saved_focus != Focus::Editor {
                        self.change_focus(saved_focus);
                    }
                }
            }
            Err(e) => {
                self.status = format!("type-cycle failed: {e}");
            }
        }
    }

    /// Ctrl+Z R — eval the currently-open Script's body against Adam.
    /// No-ops with a status message when no Script is open.
    fn bund_run_buffer(&mut self) {
        // Snapshot everything we need out of self before invoking
        // scripting_eval — that needs &mut self, so we can't hold
        // any borrow of self.opened / self.hierarchy across the
        // call.
        let (body, title) = {
            let Some(doc) = self.opened.as_ref() else {
                self.status = "bund: no buffer open — Ctrl+Z R needs an open .bund".into();
                return;
            };
            let Some(node) = self.hierarchy.get(doc.id) else {
                self.status = "bund: open buffer's node is missing from hierarchy".into();
                return;
            };
            if node.kind != NodeKind::Script {
                self.status = format!(
                    "bund: open buffer is a {}, not a script",
                    node.kind.as_str()
                );
                return;
            }
            (doc.textarea.lines().join("\n"), node.title.clone())
        };
        // Go through `scripting_eval` so the App-state-accessing
        // `ink.editor.* / ink.ai.* / ink.typst.*` stdlib words can
        // reach `self`.
        match self.scripting_eval(&body) {
            Ok(out) => {
                self.status = format_eval_output(&out, Some(&title));
            }
            Err(e) => {
                self.status = format!("bund: eval failed — {e:#}");
            }
        }
    }

    /// Ctrl+Z N — open the Add modal pre-targeted at the Scripts
    /// system book. Falls back to the standard add path if the
    /// Scripts book hasn't been seeded for some reason.
    fn bund_new_script(&mut self) {
        if let Some(scripts_id) = self.system_book_id(crate::store::SYSTEM_TAG_SCRIPTS) {
            // Same shape as open_add_modal_inner for End, but with
            // the parent forced to the Scripts book rather than
            // derived from cursor position.
            let parent_label = if let Some(scripts) = self.hierarchy.get(scripts_id) {
                self.hierarchy.slug_path(scripts)
            } else {
                "scripts".to_string()
            };
            self.modal = Modal::Adding {
                kind: NodeKind::Script,
                parent_id: Some(scripts_id),
                parent_label,
                input: TextInput::new(),
                position: InsertPosition::End,
            };
            self.status = "bund: new script under Scripts — type a title, Enter to create".into();
        } else {
            // Fall back to the default cursor-based parent picker —
            // user-added Books can also host scripts.
            self.open_add_modal(NodeKind::Script);
        }
    }

    /// Ctrl+Z E — open a modal asking for a one-shot expression.
    /// On Enter, eval against Adam and surface the result (or
    /// error) in the status bar. Reuses the BundEval modal variant.
    fn bund_open_eval_modal(&mut self) {
        self.modal = Modal::BundEval {
            input: TextInput::new(),
        };
        self.status = "bund eval: type an expression, Enter to run, Esc to cancel".into();
    }

    /// Ctrl+Z ? — list executable scripts. Starts in `Branch`
    /// scope (the cursor's nearest containing book / chapter /
    /// subchapter). `A` toggles to `ScriptsBook` scope. Enter
    /// runs the highlighted script.
    fn bund_open_script_picker(&mut self) {
        let scope = ScriptPickerScope::Branch;
        let entries = self.collect_script_entries(scope);
        if entries.is_empty() {
            // Fall back to Scripts book if the branch is empty —
            // saves the user one keystroke and matches the
            // "show me something useful" expectation.
            let fallback = self.collect_script_entries(ScriptPickerScope::ScriptsBook);
            if fallback.is_empty() {
                self.status = "bund: no scripts found (try Ctrl+Z N to create one)".into();
                return;
            }
            self.modal = Modal::ScriptPicker {
                scope: ScriptPickerScope::ScriptsBook,
                entries: fallback,
                cursor: 0,
                scroll: 0,
            };
            self.status =
                "bund: no scripts in current branch — showing Scripts book".into();
            return;
        }
        self.modal = Modal::ScriptPicker {
            scope,
            entries,
            cursor: 0,
            scroll: 0,
        };
        self.status =
            "bund: ↑↓ select · Enter run · A toggle scope · Esc cancel".into();
    }

    /// Walk the requested scope and pull every Script node out.
    /// Returns them in slug-path order so the modal listing is
    /// stable across openings.
    fn collect_script_entries(
        &self,
        scope: ScriptPickerScope,
    ) -> Vec<ScriptPickerEntry> {
        let root_id: Option<Uuid> = match scope {
            ScriptPickerScope::Branch => self.current_branch_root_id(),
            ScriptPickerScope::ScriptsBook => self
                .hierarchy
                .iter()
                .find(|n| {
                    n.kind == NodeKind::Book
                        && n.parent_id.is_none()
                        && n.title.eq_ignore_ascii_case("Scripts")
                })
                .map(|n| n.id),
        };
        let Some(root_id) = root_id else {
            return Vec::new();
        };
        let mut ids = self.hierarchy.collect_subtree(root_id);
        // collect_subtree includes the root — drop it if it's
        // not itself a script.
        let mut entries: Vec<ScriptPickerEntry> = Vec::new();
        for id in ids.drain(..) {
            let Some(node) = self.hierarchy.get(id) else {
                continue;
            };
            if node.kind != NodeKind::Script {
                continue;
            }
            let slug_path = self.hierarchy.slug_path(node);
            entries.push(ScriptPickerEntry {
                id: node.id,
                title: node.title.clone(),
                slug_path,
            });
        }
        entries.sort_by(|a, b| a.slug_path.cmp(&b.slug_path));
        entries
    }

    /// The nearest book / chapter / subchapter ancestor of the
    /// tree cursor (or the cursor itself if it already names a
    /// branch). Returns `None` if no row is selected.
    fn current_branch_root_id(&self) -> Option<Uuid> {
        let (id, _) = *self.rows.get(self.tree_cursor)?;
        let node = self.hierarchy.get(id)?;
        if matches!(
            node.kind,
            NodeKind::Subchapter | NodeKind::Chapter | NodeKind::Book
        ) {
            return Some(node.id);
        }
        // Leaf node — walk ancestors until we hit a branch.
        for anc in self.hierarchy.ancestors(node) {
            if matches!(
                anc.kind,
                NodeKind::Subchapter | NodeKind::Chapter | NodeKind::Book
            ) {
                return Some(anc.id);
            }
        }
        None
    }

    // dispatch_meta_tree was absorbed into keybind::KeyBindings —
    // the meta_sub table now carries every tree-pane chord via
    // Scope::Tree entries, and resolution flows through
    // handle_meta_action → resolve_meta_sub → run_action.

    // dispatch_meta_editor + dispatch_meta_ai were absorbed into
    // keybind::KeyBindings — every chord they handled now lives in
    // the meta_sub table under Scope::Editor / Scope::Ai. See
    // run_action for the action→handler dispatch.

    fn open_quickref(&mut self) {
        self.modal = Modal::QuickRef {
            focus: self.focus,
            scroll: 0,
        };
    }

    fn open_credits(&mut self) {
        // 1.2.5+: build a fresh ratatui-image protocol over the
        // embedded `logo.png` so the credits modal can banner it
        // at the top. The image picker is None on terminals
        // without graphics support; we fall through with no logo
        // and the modal still renders fine.
        let logo = self
            .image_picker
            .as_ref()
            .and_then(|picker| {
                embedded_logo_image().map(|img| picker.new_resize_protocol(img.clone()))
            });
        self.modal = Modal::Credits { scroll: 0, logo };
        self.status = "Credits · ↑↓/PgUp/PgDn scroll · Esc close".into();
    }

    /// Ctrl+B I — open the "current book info" panel. The content is
    /// rendered each frame in `draw_book_info_modal` so figures stay
    /// fresh as the user edits; we only stash the scroll offset here.
    fn open_book_info(&mut self) {
        self.modal = Modal::BookInfo { scroll: 0 };
        self.status =
            "Book info · ↑↓/PgUp/PgDn scroll · Esc close".into();
    }

    /// 1.3.0 PDF-1 — `Ctrl+B Q`: preview the imposition plan for the
    /// current book's built PDF, with `Enter` to impose.
    fn open_imposition_preview(&mut self) {
        let Some(book) = self.current_book_node(&self.hierarchy) else {
            self.status = "imposition: select a book first".into();
            return;
        };
        let artefacts_book = self
            .store
            .resolve_artefacts_dir(&self.cfg)
            .join(&book.slug);
        let source = artefacts_book.join(format!("{}.pdf", book.slug));
        if !source.exists() {
            self.status = "imposition: build the book first (Ctrl+B B)".into();
            return;
        }
        let doc = match crate::pdf::PdfDoc::load(&source) {
            Ok(d) => d,
            Err(e) => {
                self.status = format!("imposition: {e}");
                return;
            }
        };
        let profile = "default".to_string();
        let params = match self.cfg.imposition.resolve(&profile) {
            Ok(p) => p,
            Err(e) => {
                self.status = format!("imposition: {e}");
                return;
            }
        };
        let lines = crate::pdf::impose::preview::build(&profile, doc.page_count(), &params).lines();
        let out = artefacts_book.join(format!("{}-imposed.pdf", book.slug));
        self.modal = Modal::ImpositionPreview {
            source,
            out,
            profile,
            params,
            lines,
        };
        self.status = "Imposition preview · Enter impose · Esc cancel".into();
    }

    /// Apply the previewed imposition: load the source, impose, save (atomic).
    fn apply_imposition_preview(&mut self) {
        let (source, out, profile, params) = match &self.modal {
            Modal::ImpositionPreview {
                source,
                out,
                profile,
                params,
                ..
            } => (source.clone(), out.clone(), profile.clone(), *params),
            _ => return,
        };
        self.modal = Modal::None;
        let src = match crate::pdf::PdfDoc::load(&source) {
            Ok(d) => d,
            Err(e) => {
                self.status = format!("impose: {e}");
                return;
            }
        };
        let mut imposed = match crate::pdf::impose::impose(&src, &params) {
            Ok(d) => d,
            Err(e) => {
                self.status = format!("impose: {e}");
                return;
            }
        };
        match imposed.save(&out) {
            Ok(()) => {
                self.status = format!(
                    "imposed `{profile}` → {} ({} pages)",
                    out.display(),
                    imposed.page_count()
                )
            }
            Err(e) => self.status = format!("impose: {e}"),
        }
    }

    /// Key handler for the imposition-preview modal.
    fn imposition_preview_handle_key(&mut self, key: KeyEvent) -> bool {
        if !matches!(self.modal, Modal::ImpositionPreview { .. }) {
            return false;
        }
        match key.code {
            KeyCode::Enter => self.apply_imposition_preview(),
            KeyCode::Esc => {
                self.modal = Modal::None;
                self.status = "imposition: cancelled".into();
            }
            _ => {}
        }
        true
    }

    /// 1.3.1 SUBMISSION-1 — open the submission tracker modal, loading the
    /// `.inkhaven/submissions.json` log for the active project.
    fn open_submissions_tracker(&mut self) {
        match crate::submissions::SubmissionLog::load(&self.layout.root) {
            Ok(log) => {
                self.modal = Modal::SubmissionsTracker {
                    records: log.records,
                    cursor: 0,
                };
                self.status =
                    "Submissions · ↑↓ move · Space/s status · d remove · Esc close".into();
            }
            Err(e) => self.status = format!("submissions: {e}"),
        }
    }

    /// Persist the modal's current record list back to the sidecar.
    fn save_submissions_modal(&mut self) {
        if let Modal::SubmissionsTracker { records, .. } = &self.modal {
            let log = crate::submissions::SubmissionLog {
                records: records.clone(),
            };
            if let Err(e) = log.save(&self.layout.root) {
                self.status = format!("submissions: {e}");
            }
        }
    }

    fn submissions_tracker_handle_key(&mut self, key: KeyEvent) -> bool {
        let Modal::SubmissionsTracker { records, cursor } = &mut self.modal else {
            return false;
        };
        match key.code {
            KeyCode::Esc => {
                self.modal = Modal::None;
                self.status = "submissions: closed".into();
            }
            KeyCode::Up => {
                *cursor = cursor.saturating_sub(1);
            }
            KeyCode::Down => {
                if !records.is_empty() {
                    *cursor = (*cursor + 1).min(records.len() - 1);
                }
            }
            // Cycle the selected record's status (and persist).
            KeyCode::Char(' ') | KeyCode::Char('s') => {
                if let Some(rec) = records.get_mut(*cursor) {
                    let all = crate::submissions::SubmissionStatus::ALL;
                    let i = all.iter().position(|s| *s == rec.status).unwrap_or(0);
                    let next = all[(i + 1) % all.len()];
                    rec.status = next;
                    // A terminal response stamps today if none recorded yet.
                    if matches!(
                        next,
                        crate::submissions::SubmissionStatus::Rejected
                            | crate::submissions::SubmissionStatus::Offer
                    ) && rec.response_date.is_none()
                    {
                        rec.response_date = Some(crate::submissions::today());
                    }
                    self.save_submissions_modal();
                }
            }
            // Remove the selected record (and persist).
            KeyCode::Char('d') => {
                if *cursor < records.len() {
                    records.remove(*cursor);
                    if *cursor > 0 && *cursor >= records.len() {
                        *cursor -= 1;
                    }
                    self.save_submissions_modal();
                }
            }
            _ => {}
        }
        true
    }

    /// 1.3.1 SUBMISSION-1 P3.3 — open the generator picker, loading the
    /// current book's *cached* digest (we never block the UI building it —
    /// the user runs `inkhaven submission digest` first).
    fn open_submission_gen(&mut self) {
        let Some(book) = self.current_book_node(&self.hierarchy) else {
            self.status = "submission: select a book first".into();
            return;
        };
        match crate::book_digest::BookDigest::load(&self.layout.root, &book.slug) {
            Some(d) => {
                self.modal = Modal::SubmissionGenPicker {
                    cursor: 0,
                    context: d.as_context(),
                };
                self.status =
                    "Submission generator · ↑↓ pick · Enter → AI pane · Esc cancel".into();
            }
            None => {
                self.status =
                    "submission: no digest yet — run `inkhaven submission digest` first".into();
            }
        }
    }

    /// 1.3.2 PLANNING-1 P2 — open the structure outline (the `plan check`
    /// report) for the current book.
    fn open_plan_outline(&mut self) {
        let Some(book) = self.current_book_node(&self.hierarchy) else {
            self.status = "plan: select a book first".into();
            return;
        };
        match crate::cli::plan::build_report(&self.store, &self.layout, &self.hierarchy, &book, 0.10)
        {
            Ok((report, framework, _n)) => {
                let warn = report.warnings.len();
                let scenes = crate::cli::plan::load_scenes(&self.store, &self.hierarchy)
                    .into_iter()
                    .map(|(_, s)| s)
                    .collect();
                self.modal = Modal::PlanOutline {
                    book_title: book.title.clone(),
                    book_slug: book.slug.clone(),
                    framework,
                    report,
                    cursor: 0,
                    picking: None,
                    thread_pick: None,
                    scenes,
                    scene_view: false,
                    scene_cursor: 0,
                };
                self.status = format!(
                    "Structure · {warn} finding(s) · ↑↓ · m map · t threads · s status · v scenes · a analyze · ⏎ open · Esc"
                );
            }
            Err(e) => self.status = format!("plan: {e}"),
        }
    }

    /// 1.3.6 EDITORIAL-1 P1 — open the Editorial Pass cockpit: the ranked
    /// revision worklist (`inkhaven edit`), walkable + jump-to-location.
    fn open_editorial_pass(&mut self) {
        match crate::cli::editorial::collect(&self.layout.root, None, None, false) {
            Ok(report) => {
                let n = report.findings.len();
                let deferred = report.deferred;
                let stale = report.stale;
                self.modal = Modal::EditorialPass {
                    findings: report.findings,
                    cursor: 0,
                    scroll: 0,
                    filter: None,
                };
                let mut note = if deferred > 0 {
                    format!(" ({deferred} deferred)")
                } else {
                    String::new()
                };
                if stale {
                    note.push_str(" · ⚠ may be stale (Ctrl+V Shift+F)");
                }
                self.status = format!(
                    "Editorial Pass · {n} finding(s){note} · ↑↓ · ⏎ jump · ✎ f fix · F fix-all · s skip · d defer · Esc"
                );
            }
            Err(e) => self.status = format!("edit: {e}"),
        }
    }

    fn editorial_pass_handle_key(&mut self, key: KeyEvent) -> bool {
        // Read everything into locals, then drop the borrow before calling
        // self methods (the established modal pattern).
        let (filtered_len, cursor, cats, target, has_jump, sel_fp, sel_rewrite, batch) = {
            let Modal::EditorialPass { findings, cursor, filter, .. } = &self.modal else {
                return false;
            };
            let mut cats: Vec<String> = findings.iter().map(|f| f.category.clone()).collect();
            cats.sort();
            cats.dedup();
            let keep = |f: &&crate::editorial::EditorialFinding| {
                filter.as_deref().is_none_or(|c| f.category == c)
            };
            let filtered_len = findings.iter().filter(keep).count();
            let sel = findings.iter().filter(keep).nth(*cursor);
            // every AI-rewritable finding in the current view, in display order —
            // the queue `F` walks.
            let batch = crate::editorial::batch_fix_queue(findings, filter.as_deref());
            (
                filtered_len,
                *cursor,
                cats,
                sel.and_then(|f| f.location.paragraph),
                sel.map(|f| f.location.paragraph.is_some()).unwrap_or(false),
                sel.map(|f| f.fingerprint()),
                // (category, paragraph, span) when the finding is AI-rewritable
                sel.filter(|f| f.rewritable())
                    .map(|f| (f.category.clone(), f.location.paragraph.unwrap(), f.location.char_range)),
                batch,
            )
        };
        match key.code {
            KeyCode::Esc => {
                self.modal = Modal::None;
                self.status = "editorial: closed".into();
            }
            KeyCode::Up => self.set_editorial_cursor(cursor.saturating_sub(1)),
            KeyCode::Down => {
                if filtered_len > 0 {
                    self.set_editorial_cursor((cursor + 1).min(filtered_len - 1));
                }
            }
            KeyCode::Char('[') => self.cycle_editorial_filter(&cats, false),
            KeyCode::Char(']') => self.cycle_editorial_filter(&cats, true),
            // skip (session) / defer (persist) the selected finding.
            KeyCode::Char('s') => {
                if let Some(fp) = sel_fp {
                    self.editorial_remove(&fp, false);
                }
            }
            KeyCode::Char('d') => {
                if let Some(fp) = sel_fp {
                    self.editorial_remove(&fp, true);
                }
            }
            // clear all deferrals + reload.
            KeyCode::Char('D') => self.editorial_clear_deferred(),
            // AI rewrite-in-place: open the paragraph, stream a fix → diff.
            KeyCode::Char('f') => match sel_rewrite {
                Some((category, pid, span)) => {
                    self.modal = Modal::None;
                    match self.open_paragraph_by_uuid(pid) {
                        Ok(()) => self.start_editorial_rewrite(&category, span),
                        Err(e) => self.status = format!("edit: {e}"),
                    }
                }
                None => {
                    self.status =
                        "editorial: this finding isn't AI-rewritable — jump (⏎) and edit it".into();
                }
            },
            // batch fix-all: walk every rewritable finding in the current
            // view through the per-item diff review.
            KeyCode::Char('F') => {
                if batch.is_empty() {
                    self.status =
                        "editorial: no AI-rewritable findings in this view (filter to echo / pacing / show-tell / filter)".into();
                } else {
                    let total = batch.len();
                    self.modal = Modal::None;
                    self.editorial_batch = Some(EditorialBatch {
                        queue: batch.into_iter().collect(),
                        total,
                        applied: 0,
                        skipped: 0,
                    });
                    self.advance_editorial_batch();
                }
            }
            KeyCode::Enter => match (has_jump, target) {
                (true, Some(pid)) => {
                    self.modal = Modal::None;
                    match self.open_paragraph_by_uuid(pid) {
                        Ok(()) => self.status = "→ jumped to the finding".into(),
                        Err(e) => self.status = format!("edit: {e}"),
                    }
                }
                _ => self.status = "editorial: no jump target (book-level finding)".into(),
            },
            _ => {}
        }
        true
    }

    /// 1.3.9 EDITORIAL-3 P3 — launch the next fix in an active batch: pop the
    /// front of the queue, open its paragraph, and stream the (span-aware)
    /// rewrite. `pump_inference` pops the diff review on completion; accepting
    /// or rejecting it calls back here for the next. When the queue drains the
    /// batch is cleared with a summary. A paragraph that won't open is counted
    /// as skipped and the walk continues.
    fn advance_editorial_batch(&mut self) {
        let next = match self.editorial_batch.as_mut() {
            Some(b) => b.queue.pop_front(),
            None => return,
        };
        match next {
            Some((pid, category, span)) => {
                let (idx, total) = {
                    let b = self.editorial_batch.as_ref().unwrap();
                    (b.total - b.queue.len(), b.total)
                };
                match self.open_paragraph_by_uuid(pid) {
                    Ok(()) => {
                        self.start_editorial_rewrite(&category, span);
                        self.status = format!(
                            "editorial batch {idx}/{total} ({category}): streaming · a accept · r skip · Esc stop"
                        );
                    }
                    Err(e) => {
                        if let Some(b) = self.editorial_batch.as_mut() {
                            b.skipped += 1;
                        }
                        self.status = format!("editorial batch: skipped one ({e})");
                        self.advance_editorial_batch();
                    }
                }
            }
            None => {
                let (applied, skipped) = self
                    .editorial_batch
                    .as_ref()
                    .map(|b| (b.applied, b.skipped))
                    .unwrap_or((0, 0));
                self.editorial_batch = None;
                self.status =
                    format!("editorial batch: done — {applied} applied, {skipped} skipped");
            }
        }
    }

    /// Remove the selected finding from the live worklist — `persist` also
    /// adds it to the defer sidecar so it won't resurface until the prose
    /// changes. (1.3.6 P2)
    fn editorial_remove(&mut self, fingerprint: &str, persist: bool) {
        if persist {
            if let Err(e) = crate::editorial::Dismissed::defer(&self.layout.root, fingerprint) {
                self.status = format!("edit: defer failed: {e}");
                return;
            }
        }
        if let Modal::EditorialPass { findings, cursor, filter, .. } = &mut self.modal {
            findings.retain(|f| f.fingerprint() != fingerprint);
            let flen = findings
                .iter()
                .filter(|f| filter.as_deref().is_none_or(|c| f.category == c))
                .count();
            *cursor = (*cursor).min(flen.saturating_sub(1));
        }
        self.status = if persist {
            "deferred — won't resurface until the prose changes".into()
        } else {
            "skipped (this session)".into()
        };
    }

    fn editorial_clear_deferred(&mut self) {
        if let Err(e) = crate::editorial::Dismissed::clear(&self.layout.root) {
            self.status = format!("edit: {e}");
            return;
        }
        self.open_editorial_pass();
        self.status = "cleared all deferrals — re-scanned".into();
    }

    /// 1.3.8 WORLD-1 P2 — open the story-bible view: the world consolidated
    /// (characters + continuity-bible attributes, places, artefacts, facts).
    fn open_story_bible(&mut self) {
        let rows = self.build_story_bible_rows();
        if rows.is_empty() {
            self.status =
                "story bible: nothing yet — collect Characters / Places / Facts first".into();
            return;
        }
        let cursor = rows
            .iter()
            .position(|r| r.kind != crate::tui::modal::BibleRowKind::Header)
            .unwrap_or(0);
        self.modal = Modal::StoryBible { rows, cursor };
        self.status = "Story bible · ↑↓ · ⏎ jump to source · Esc".into();
    }

    fn build_story_bible_rows(&self) -> Vec<crate::tui::modal::BibleRow> {
        use crate::store::{
            SYSTEM_TAG_ARTEFACTS, SYSTEM_TAG_CHARACTERS, SYSTEM_TAG_FACTS, SYSTEM_TAG_PLACES,
        };
        let bible = crate::continuity_bible::ContinuityBible::load(&self.layout.root).unwrap_or_default();
        let mut attrs: std::collections::BTreeMap<String, Vec<&crate::continuity_bible::CharacterFact>> =
            std::collections::BTreeMap::new();
        for f in &bible.facts {
            attrs.entry(f.character.to_lowercase()).or_default().push(f);
        }
        // 1.3.10 — semantic-drift sidecar: per-entity conflicts + the retrieved
        // description trail, both read straight from `.inkhaven/drift.json` (no
        // recomputation / embedding load in the TUI).
        let drift = crate::drift::DriftReport::load(&self.layout.root).unwrap_or_default();
        let mut drift_conflicts: std::collections::BTreeMap<String, Vec<&crate::drift::DriftConflict>> =
            std::collections::BTreeMap::new();
        for c in &drift.conflicts {
            drift_conflicts.entry(c.entity.to_lowercase()).or_default().push(c);
        }
        let mut drift_desc: std::collections::BTreeMap<String, &crate::drift::EntityDescriptions> =
            std::collections::BTreeMap::new();
        for d in &drift.descriptions {
            drift_desc.insert(d.entity.to_lowercase(), d);
        }
        let dv = DriftBibleView { conflicts: &drift_conflicts, descriptions: &drift_desc };
        let mut rows = Vec::new();
        // 1.3.11 — world-health banner: the same consistency snapshot
        // `inkhaven world` prints, from the already-open store (no reopen).
        let world = crate::cli::world::report_from(
            &self.store,
            &self.hierarchy,
            &self.cfg,
            &self.layout.root,
        );
        rows.push(crate::tui::modal::BibleRow {
            kind: crate::tui::modal::BibleRowKind::Header,
            text: world.summary(),
            jump: None,
        });
        self.bible_section(&mut rows, SYSTEM_TAG_CHARACTERS, "CHARACTERS", Some(&attrs), Some(&dv));
        self.bible_section(&mut rows, SYSTEM_TAG_PLACES, "PLACES", None, Some(&dv));
        self.bible_section(&mut rows, SYSTEM_TAG_ARTEFACTS, "ARTEFACTS", None, Some(&dv));
        self.bible_section(&mut rows, SYSTEM_TAG_FACTS, "FACTS", None, None);
        rows
    }

    fn bible_section(
        &self,
        rows: &mut Vec<crate::tui::modal::BibleRow>,
        tag: &str,
        title: &str,
        attrs: Option<&std::collections::BTreeMap<String, Vec<&crate::continuity_bible::CharacterFact>>>,
        drift: Option<&DriftBibleView>,
    ) {
        use crate::tui::modal::{BibleRow, BibleRowKind};
        let Some(book) = self
            .hierarchy
            .iter()
            .find(|n| n.kind == NodeKind::Book && n.system_tag.as_deref() == Some(tag))
        else {
            return;
        };
        let entries: Vec<&Node> = self
            .hierarchy
            .collect_subtree(book.id)
            .into_iter()
            .filter_map(|id| self.hierarchy.get(id))
            .filter(|n| n.kind == NodeKind::Paragraph)
            .collect();
        if entries.is_empty() {
            return;
        }
        rows.push(BibleRow {
            kind: BibleRowKind::Header,
            text: format!("{title} ({})", entries.len()),
            jump: None,
        });
        for n in entries {
            rows.push(BibleRow {
                kind: BibleRowKind::Entry,
                text: n.title.clone(),
                jump: Some(n.id),
            });
            if let Some(map) = attrs {
                if let Some(facts) = map.get(&n.title.to_lowercase()) {
                    for f in facts {
                        rows.push(BibleRow {
                            kind: BibleRowKind::Attr,
                            text: format!("{}: {} (ch {})", f.attribute, f.value, f.chapter),
                            jump: None,
                        });
                    }
                }
            }
            // 1.3.10 — drift: flag contradictory descriptions of this entity,
            // then show its full description trail so the author sees the
            // sequence that diverged (only for flagged entities, to keep the
            // bible compact).
            if let Some(dv) = drift {
                let key = n.title.trim().to_lowercase();
                if let Some(confs) = dv.conflicts.get(&key) {
                    for c in confs {
                        rows.push(BibleRow {
                            kind: BibleRowKind::Drift,
                            text: format!(
                                "⚠ drift: “{}” (ch {}) ⟷ “{}” (ch {})",
                                c.a, c.chapter_a, c.b, c.chapter_b
                            ),
                            jump: c.paragraph_b,
                        });
                        if !c.detail.trim().is_empty() {
                            rows.push(BibleRow {
                                kind: BibleRowKind::Attr,
                                text: format!("↳ {}", c.detail),
                                jump: None,
                            });
                        }
                    }
                    if let Some(desc) = dv.descriptions.get(&key) {
                        for s in &desc.snippets {
                            rows.push(BibleRow {
                                kind: BibleRowKind::Attr,
                                text: format!("· ch {}: {}", s.chapter, s.text),
                                jump: Some(s.paragraph),
                            });
                        }
                    }
                }
            }
        }
    }

    fn story_bible_handle_key(&mut self, key: KeyEvent) -> bool {
        let (n, cursor, jump) = {
            let Modal::StoryBible { rows, cursor } = &self.modal else {
                return false;
            };
            (rows.len(), *cursor, rows.get(*cursor).and_then(|r| r.jump))
        };
        match key.code {
            KeyCode::Esc => {
                self.modal = Modal::None;
                self.status = "story bible: closed".into();
            }
            KeyCode::Up => self.set_story_bible_cursor(cursor.saturating_sub(1)),
            KeyCode::Down => {
                if n > 0 {
                    self.set_story_bible_cursor((cursor + 1).min(n - 1));
                }
            }
            KeyCode::Enter => match jump {
                Some(pid) => {
                    self.modal = Modal::None;
                    match self.open_paragraph_by_uuid(pid) {
                        Ok(()) => self.status = "→ jumped".into(),
                        Err(e) => self.status = format!("bible: {e}"),
                    }
                }
                None => self.status = "story bible: that row has no source to jump to".into(),
            },
            _ => {}
        }
        true
    }

    fn set_story_bible_cursor(&mut self, v: usize) {
        if let Modal::StoryBible { cursor, .. } = &mut self.modal {
            *cursor = v;
        }
    }

    fn set_editorial_cursor(&mut self, v: usize) {
        if let Modal::EditorialPass { cursor, scroll, .. } = &mut self.modal {
            *cursor = v;
            // keep the cursor in a sensible scroll window
            if v < *scroll {
                *scroll = v;
            }
        }
    }

    /// Cycle the category filter `None → cat0 → … → None` (or backwards),
    /// resetting the cursor.
    fn cycle_editorial_filter(&mut self, cats: &[String], forward: bool) {
        if let Modal::EditorialPass { filter, cursor, scroll, .. } = &mut self.modal {
            // ring: None, then each category
            let cur = filter
                .as_deref()
                .and_then(|c| cats.iter().position(|x| x == c).map(|i| i + 1))
                .unwrap_or(0);
            let n = cats.len() + 1;
            let next = if forward { (cur + 1) % n } else { (cur + n - 1) % n };
            *filter = if next == 0 { None } else { Some(cats[next - 1].clone()) };
            *cursor = 0;
            *scroll = 0;
        }
    }

    fn plan_outline_handle_key(&mut self, key: KeyEvent) -> bool {
        // Read state, then drop the modal borrow so the edit helpers can
        // touch the store.
        let (n_beats, n_chaps, n_threads, n_scenes, cursor, scene_cursor, scene_sel, picking, thread_pick, scene_view, book_slug, framework) = {
            let Modal::PlanOutline {
                report,
                cursor,
                scene_cursor,
                picking,
                thread_pick,
                scene_view,
                scenes,
                book_slug,
                framework,
                ..
            } = &self.modal
            else {
                return false;
            };
            (
                report.beats.len(),
                report.chapters.len(),
                report.available_threads.len(),
                scenes.len(),
                *cursor,
                *scene_cursor,
                scenes.get(*scene_cursor).map(|s| (s.chapter.clone(), s.title.clone())),
                *picking,
                *thread_pick,
                *scene_view,
                book_slug.clone(),
                framework.clone(),
            )
        };
        // ── scene board sub-mode (`v`) ──
        if scene_view {
            match key.code {
                KeyCode::Esc | KeyCode::Char('v') => self.set_plan_scene_view(false),
                KeyCode::Up => self.set_plan_scene_cursor(scene_cursor.saturating_sub(1)),
                KeyCode::Down => {
                    if n_scenes > 0 {
                        self.set_plan_scene_cursor((scene_cursor + 1).min(n_scenes - 1));
                    }
                }
                // `g` — regenerate the selected card from its chapter prose.
                KeyCode::Char('g') => {
                    if let Some((chapter, title)) = scene_sel {
                        if chapter.trim().is_empty() {
                            self.status = "plan: this card has no chapter to scaffold from".into();
                        } else {
                            self.fire_scene_scaffold(&chapter, &title);
                        }
                    }
                }
                _ => {}
            }
            return true;
        }
        // ── picking threads for the cursor beat (Space toggles) ──
        if let Some(tp) = thread_pick {
            match key.code {
                // toggles persist immediately, so Enter/Esc just close.
                KeyCode::Esc | KeyCode::Enter | KeyCode::Char('t') => self.set_plan_thread_pick(None),
                KeyCode::Up => self.set_plan_thread_pick(Some(tp.saturating_sub(1))),
                KeyCode::Down => {
                    if n_threads > 0 {
                        self.set_plan_thread_pick(Some((tp + 1).min(n_threads - 1)));
                    }
                }
                KeyCode::Char(' ') => self.toggle_plan_thread(cursor, tp),
                _ => {}
            }
            return true;
        }
        match (picking, key.code) {
            // ── picking a chapter for the cursor beat ──
            (Some(_), KeyCode::Esc) => self.set_plan_picking(None),
            (Some(pc), KeyCode::Up) => self.set_plan_picking(Some(pc.saturating_sub(1))),
            (Some(pc), KeyCode::Down) => {
                if n_chaps > 0 {
                    self.set_plan_picking(Some((pc + 1).min(n_chaps - 1)));
                }
            }
            (Some(pc), KeyCode::Enter) => self.map_plan_beat(cursor, pc),
            (Some(_), _) => {}
            // ── browsing beats ──
            (None, KeyCode::Esc) => {
                self.modal = Modal::None;
                self.status = "plan: closed".into();
            }
            (None, KeyCode::Up) => self.set_plan_cursor(cursor.saturating_sub(1)),
            (None, KeyCode::Down) => {
                if n_beats > 0 {
                    self.set_plan_cursor((cursor + 1).min(n_beats - 1));
                }
            }
            (None, KeyCode::Char('m')) => {
                if n_chaps > 0 {
                    self.set_plan_picking(Some(0));
                    self.status = "Map this beat to a chapter · ↑↓ · Enter · Esc".into();
                } else {
                    self.status = "plan: no chapters to map to".into();
                }
            }
            (None, KeyCode::Char('s')) => self.cycle_plan_status(cursor),
            (None, KeyCode::Char('a')) => self.fire_plan_analysis(&book_slug, &framework),
            (None, KeyCode::Char('t')) => {
                if n_threads > 0 {
                    self.set_plan_thread_pick(Some(0));
                    self.status = "Link threads · ↑↓ · Space toggle · Esc".into();
                } else {
                    self.status = "plan: no threads yet — add with `inkhaven thread add`".into();
                }
            }
            (None, KeyCode::Char('v')) => {
                if n_scenes > 0 {
                    self.set_plan_scene_view(true);
                    self.status = "Scene board · ↑↓ · v/Esc back".into();
                } else {
                    self.status =
                        "plan: no scene cards yet — add with `inkhaven plan scene add`".into();
                }
            }
            (None, KeyCode::Enter) => self.jump_to_beat_chapter(cursor),
            (None, _) => {}
        }
        true
    }

    fn set_plan_scene_view(&mut self, v: bool) {
        if let Modal::PlanOutline { scene_view, scene_cursor, .. } = &mut self.modal {
            *scene_view = v;
            if v {
                *scene_cursor = 0;
            }
        }
    }
    fn set_plan_scene_cursor(&mut self, v: usize) {
        if let Modal::PlanOutline { scene_cursor, .. } = &mut self.modal {
            *scene_cursor = v;
        }
    }

    fn set_plan_cursor(&mut self, v: usize) {
        if let Modal::PlanOutline { cursor, .. } = &mut self.modal {
            *cursor = v;
        }
    }
    fn set_plan_picking(&mut self, v: Option<usize>) {
        if let Modal::PlanOutline { picking, .. } = &mut self.modal {
            *picking = v;
        }
    }
    fn set_plan_thread_pick(&mut self, v: Option<usize>) {
        if let Modal::PlanOutline { thread_pick, .. } = &mut self.modal {
            *thread_pick = v;
        }
    }

    /// Toggle the `thread_idx`-th available thread on/off the `beat_idx`
    /// beat (write-back + refresh) — the P2 thread-link primitive, reusing
    /// the P0 `edit_beat`.
    fn toggle_plan_thread(&mut self, beat_idx: usize, thread_idx: usize) {
        let Some(slug) = (match &self.modal {
            Modal::PlanOutline { report, .. } => report.available_threads.get(thread_idx).cloned(),
            _ => None,
        }) else {
            return;
        };
        let pairs = crate::cli::plan::load_beats(&self.store, &self.hierarchy);
        let Some((id, _)) = pairs.get(beat_idx) else { return };
        let mut node = match self.hierarchy.get(*id) {
            Some(n) => n.clone(),
            None => return,
        };
        let mut added = false;
        if let Err(e) = crate::cli::plan::edit_beat(&self.store, &mut node, |b| {
            if let Some(pos) = b.threads.iter().position(|t| t == &slug) {
                b.threads.remove(pos);
            } else {
                b.threads.push(slug.clone());
                added = true;
            }
        }) {
            self.status = format!("plan: {e}");
            return;
        }
        self.rebuild_plan_outline();
        self.status = format!("{} {slug}", if added { "linked" } else { "unlinked" });
    }

    /// `Enter` in the outline: open the cursor beat's mapped chapter (its
    /// first paragraph) in the editor. A nudge if the beat is unmapped.
    fn jump_to_beat_chapter(&mut self, beat_idx: usize) {
        let (book_slug, slug) = match &self.modal {
            Modal::PlanOutline {
                book_slug, report, ..
            } => (
                book_slug.clone(),
                report.beats.get(beat_idx).and_then(|b| b.mapped_chapter.clone()),
            ),
            _ => return,
        };
        let Some(slug) = slug else {
            self.status = "plan: this beat isn't mapped to a chapter yet (press m)".into();
            return;
        };
        // Scope the chapter lookup to this report's book (slugs aren't
        // globally unique across books).
        let chapter_id = self
            .hierarchy
            .iter()
            .find(|n| n.kind == NodeKind::Book && n.slug == book_slug)
            .map(|b| b.id)
            .and_then(|bid| {
                self.hierarchy.collect_subtree(bid).into_iter().find(|id| {
                    self.hierarchy
                        .get(*id)
                        .map(|n| n.kind == NodeKind::Chapter && n.slug == slug)
                        .unwrap_or(false)
                })
            });
        let Some(chapter_id) = chapter_id else {
            self.status = format!("plan: chapter `{slug}` not found");
            return;
        };
        let first_para = self.hierarchy.collect_subtree(chapter_id).into_iter().find(|id| {
            self.hierarchy
                .get(*id)
                .map(|n| n.kind == NodeKind::Paragraph)
                .unwrap_or(false)
        });
        let Some(pid) = first_para else {
            self.status = format!("plan: chapter `{slug}` has no paragraphs yet");
            return;
        };
        self.modal = Modal::None;
        match self.open_paragraph_by_uuid(pid) {
            Ok(()) => self.status = format!("→ {slug}"),
            Err(e) => self.status = format!("plan: {e}"),
        }
    }

    /// Map the `beat_idx` beat to the `chap_idx` chapter (write-back +
    /// refresh) — reuses the P0 `edit_beat` primitive.
    fn map_plan_beat(&mut self, beat_idx: usize, chap_idx: usize) {
        let Some(chapter) = (match &self.modal {
            Modal::PlanOutline { report, .. } => report.chapters.get(chap_idx).map(|c| c.slug.clone()),
            _ => None,
        }) else {
            return;
        };
        let pairs = crate::cli::plan::load_beats(&self.store, &self.hierarchy);
        let Some((id, _)) = pairs.get(beat_idx) else { return };
        let mut node = match self.hierarchy.get(*id) {
            Some(n) => n.clone(),
            None => return,
        };
        if let Err(e) =
            crate::cli::plan::edit_beat(&self.store, &mut node, |b| b.mapped_chapter = Some(chapter.clone()))
        {
            self.status = format!("plan: {e}");
            return;
        }
        self.rebuild_plan_outline();
        self.status = format!("mapped → {chapter}");
    }

    fn cycle_plan_status(&mut self, beat_idx: usize) {
        let pairs = crate::cli::plan::load_beats(&self.store, &self.hierarchy);
        let Some((id, _)) = pairs.get(beat_idx) else { return };
        let mut node = match self.hierarchy.get(*id) {
            Some(n) => n.clone(),
            None => return,
        };
        if let Err(e) = crate::cli::plan::edit_beat(&self.store, &mut node, |b| {
            b.status = match b.status.as_str() {
                "planned" => "drafted",
                "drafted" => "done",
                _ => "planned",
            }
            .to_string();
        }) {
            self.status = format!("plan: {e}");
            return;
        }
        self.rebuild_plan_outline();
    }

    /// Recompute the report after an edit (keeps cursor; clears picking).
    fn rebuild_plan_outline(&mut self) {
        let Some(book) = self.current_book_node(&self.hierarchy) else {
            return;
        };
        let report =
            match crate::cli::plan::build_report(&self.store, &self.layout, &self.hierarchy, &book, 0.10) {
                Ok((r, _, _)) => r,
                Err(e) => {
                    self.status = format!("plan: {e}");
                    return;
                }
            };
        let fresh_scenes: Vec<crate::planning::Scene> =
            crate::cli::plan::load_scenes(&self.store, &self.hierarchy)
                .into_iter()
                .map(|(_, s)| s)
                .collect();
        if let Modal::PlanOutline {
            report: r,
            cursor,
            picking,
            scenes,
            scene_cursor,
            ..
        } = &mut self.modal
        {
            let max = report.beats.len().saturating_sub(1);
            if *cursor > max {
                *cursor = max;
            }
            *r = report;
            *picking = None;
            *scene_cursor = (*scene_cursor).min(fresh_scenes.len().saturating_sub(1));
            *scenes = fresh_scenes;
        }
    }

    /// 1.3.2 PLANNING-1 P3 — stream the AI structure analysis into the AI
    /// pane, over the book's **cached** digest + the framework.
    fn fire_plan_analysis(&mut self, book_slug: &str, framework: &str) {
        let Some(fw) = crate::planning::Framework::parse(framework) else {
            self.status = format!("plan: unknown framework `{framework}`");
            return;
        };
        let Some(digest) = crate::book_digest::BookDigest::load(&self.layout.root, book_slug) else {
            self.status =
                "plan: no digest yet — run `inkhaven plan analyze` or `submission digest` first".into();
            return;
        };
        let system =
            self.resolve_prompt_template(crate::planning::ANALYZE_SLUG, || {
                crate::planning::analyze_system_prompt().to_string()
            });
        let scenes: Vec<crate::planning::Scene> =
            crate::cli::plan::load_scenes(&self.store, &self.hierarchy)
                .into_iter()
                .map(|(_, s)| s)
                .collect();
        let user_prompt = crate::planning::analyze_user_prompt(fw, &digest.as_context(), &scenes);
        let (model, _env) = match self.ai.resolve_provider(&self.cfg.llm, None) {
            Ok(p) => p,
            Err(e) => {
                self.status = format!("plan: can't reach LLM ({e})");
                return;
            }
        };
        let model = model.to_string();
        let provider = self.ai.default_provider.clone();
        self.chat_history.clear();
        self.inference = None;
        self.ai_mode = AiMode::None;
        let rx = spawn_chat_stream(
            self.ai.client.clone(),
            model.clone(),
            Some(system),
            Vec::new(),
            user_prompt.clone(),
        );
        let started_at = std::time::Instant::now();
        self.inference = Some(Inference {
            provider,
            model,
            response: String::new(),
            status: InferenceStatus::Streaming,
            rx,
            started_at,
        });
        self.lift_target = Some(LiftTarget {
            book_tag: crate::store::SYSTEM_TAG_PLANNING,
            book_label: "Planning",
            title: "Structural Analysis".into(),
            what: "structural analysis".into(),
            scene_chapter: None,
            stamp: started_at,
        });
        self.pending_chat_user_msg = Some(user_prompt);
        self.modal = Modal::None;
        self.change_focus(Focus::Ai);
        self.status = "Analyzing structure → AI pane (L files it into Planning)".into();
    }

    /// 1.3.5 PLANNING-4 P1 — stream a scene-card proposal (goal/conflict/
    /// disaster) for `chapter_slug`'s prose into the AI pane; `L` parses it
    /// and upserts the card titled `card_title`.
    fn fire_scene_scaffold(&mut self, chapter_slug: &str, card_title: &str) {
        let chapter = self
            .current_book_node(&self.hierarchy)
            .map(|b| b.id)
            .and_then(|bid| {
                self.hierarchy
                    .collect_subtree(bid)
                    .into_iter()
                    .filter_map(|id| self.hierarchy.get(id))
                    .find(|n| n.kind == NodeKind::Chapter && n.slug.eq_ignore_ascii_case(chapter_slug))
                    .cloned()
            });
        let Some(chapter) = chapter else {
            self.status = format!("plan: chapter `{chapter_slug}` not found");
            return;
        };
        let prose: String =
            crate::cli::book_walk::chapter_paragraphs_raw(&self.layout, &self.hierarchy, chapter.id)
                .iter()
                .map(|p| crate::audiobook::typst_to_plain(p))
                .filter(|p| !crate::manuscript::is_scene_break(p))
                .collect::<Vec<_>>()
                .join("\n\n");
        if prose.trim().is_empty() {
            self.status = format!("plan: `{chapter_slug}` has no prose to scaffold from");
            return;
        }
        let prose: String = prose.chars().take(6000).collect();
        let system = self.resolve_prompt_template(crate::planning::SCENE_SCAFFOLD_SLUG, || {
            crate::planning::scene_scaffold_system_prompt().to_string()
        });
        let user_prompt = crate::planning::scene_scaffold_user_prompt(&chapter.title, &prose);
        let (model, _env) = match self.ai.resolve_provider(&self.cfg.llm, None) {
            Ok(p) => p,
            Err(e) => {
                self.status = format!("plan: can't reach LLM ({e})");
                return;
            }
        };
        let model = model.to_string();
        let provider = self.ai.default_provider.clone();
        self.chat_history.clear();
        self.inference = None;
        self.ai_mode = AiMode::None;
        let rx = spawn_chat_stream(
            self.ai.client.clone(),
            model.clone(),
            Some(system),
            Vec::new(),
            user_prompt.clone(),
        );
        let started_at = std::time::Instant::now();
        self.inference = Some(Inference {
            provider,
            model,
            response: String::new(),
            status: InferenceStatus::Streaming,
            rx,
            started_at,
        });
        self.lift_target = Some(LiftTarget {
            book_tag: crate::store::SYSTEM_TAG_PLANNING,
            book_label: "Planning",
            title: card_title.to_string(),
            what: "scene card".into(),
            scene_chapter: Some(chapter_slug.to_string()),
            stamp: started_at,
        });
        self.pending_chat_user_msg = Some(user_prompt);
        self.modal = Modal::None;
        self.change_focus(Focus::Ai);
        self.status = format!("Scaffolding scene → AI pane (L files the card `{card_title}`)");
    }

    fn submission_gen_handle_key(&mut self, key: KeyEvent) -> bool {
        let Modal::SubmissionGenPicker { cursor, context } = &mut self.modal else {
            return false;
        };
        let n = crate::submission_gen::SubmissionKind::ALL.len();
        match key.code {
            KeyCode::Esc => {
                self.modal = Modal::None;
                self.status = "submission: cancelled".into();
            }
            KeyCode::Up => *cursor = cursor.saturating_sub(1),
            KeyCode::Down => *cursor = (*cursor + 1).min(n - 1),
            KeyCode::Enter => {
                let kind = crate::submission_gen::SubmissionKind::ALL[(*cursor).min(n - 1)];
                let context = context.clone();
                self.fire_submission_generator(kind, &context);
            }
            _ => {}
        }
        true
    }

    /// Fire a generator as a one-shot streaming inference into the AI pane,
    /// resolving the system prompt through the 3-tier resolver (Prompts
    /// book → `prompts.hjson` → built-in, language-aware).
    fn fire_submission_generator(
        &mut self,
        kind: crate::submission_gen::SubmissionKind,
        context: &str,
    ) {
        let system = self.resolve_prompt_template(kind.slug(), || kind.builtin_system().to_string());
        let user_prompt = kind.user_prompt(context);
        let (model, _env) = match self.ai.resolve_provider(&self.cfg.llm, None) {
            Ok(pair) => pair,
            Err(e) => {
                self.status = format!("submission: can't reach LLM ({e})");
                return;
            }
        };
        let model = model.to_string();
        let provider = self.ai.default_provider.clone();
        self.chat_history.clear();
        self.inference = None;
        self.ai_mode = AiMode::None;
        let rx = spawn_chat_stream(
            self.ai.client.clone(),
            model.clone(),
            Some(system),
            Vec::new(),
            user_prompt.clone(),
        );
        let started_at = std::time::Instant::now();
        self.inference = Some(Inference {
            provider,
            model,
            response: String::new(),
            status: InferenceStatus::Streaming,
            rx,
            started_at,
        });
        self.lift_target = Some(LiftTarget {
            book_tag: crate::store::SYSTEM_TAG_SUBMISSIONS,
            book_label: "Submissions",
            title: kind.title().to_string(),
            what: kind.title().to_lowercase(),
            scene_chapter: None,
            stamp: started_at,
        });
        self.pending_chat_user_msg = Some(user_prompt);
        self.modal = Modal::None;
        self.change_focus(Focus::Ai);
        self.status = format!(
            "Generating {} → AI pane (L files it into Submissions; or save via `inkhaven submission {}`)",
            kind.title(),
            kind.slug(),
        );
    }

    /// Whether a `lift_target` exists *and* belongs to the inference
    /// currently shown in the AI pane (matched by `started_at`). Guards the
    /// `L` chord so a stale target from an earlier generator can never file
    /// the wrong text, and so `L` is inert for ordinary chat replies.
    pub(super) fn lift_target_matches_current(&self) -> bool {
        matches!(
            (&self.lift_target, &self.inference),
            (Some(t), Some(inf)) if t.stamp == inf.started_at
        )
    }

    /// `L` in the AI pane: file the finished generator response into its
    /// system book — a submission draft into Submissions, the structural
    /// analysis into Planning — upserting by title so re-running a generator
    /// overwrites rather than piling up. Mirrors the CLI `write_draft` path.
    pub(super) fn lift_inference_to_book(&mut self) {
        let Some(target) = self.lift_target.clone() else {
            return;
        };
        let text = match self.inference.as_ref() {
            Some(inf) if inf.started_at == target.stamp => inf.response.trim().to_string(),
            // The shown inference is no longer the one this target was made
            // for — drop the stale target rather than file the wrong text.
            _ => {
                self.lift_target = None;
                return;
            }
        };
        if text.is_empty() {
            self.status = "lift: nothing to file yet".into();
            return;
        }
        // Scene-card lift (1.3.5 P1): parse the response into
        // goal/conflict/disaster and upsert the card, rather than filing raw
        // prose into a system book.
        if let Some(chapter) = &target.scene_chapter {
            let (goal, conflict, disaster) = crate::planning::parse_scene_scaffold(&text);
            if goal.is_empty() && conflict.is_empty() && disaster.is_empty() {
                self.status = "lift: no goal/conflict/disaster found in the reply".into();
                return;
            }
            match crate::cli::plan::upsert_scene_card(
                &self.store,
                &self.cfg,
                &target.title,
                chapter,
                &goal,
                &conflict,
                &disaster,
            ) {
                Ok(()) => {
                    self.status = format!("Filed scene card → {}", target.title);
                    self.lift_target = None;
                    self.reload_hierarchy();
                }
                Err(e) => self.status = format!("lift failed: {e}"),
            }
            return;
        }
        match self.upsert_system_draft(target.book_tag, &target.title, &text) {
            Ok(slug) => {
                self.status = format!(
                    "Filed {} → {}/{slug}",
                    target.what, target.book_label
                );
                self.lift_target = None;
                self.reload_hierarchy();
            }
            Err(e) => self.status = format!("lift failed: {e}"),
        }
    }

    /// Upsert a prose draft paragraph (keyed by title, case-insensitive)
    /// into the system book tagged `book_tag`. Disk-first, then the bdslib
    /// content update — the system-book write discipline. Returns the
    /// paragraph slug.
    fn upsert_system_draft(
        &mut self,
        book_tag: &str,
        title: &str,
        body_text: &str,
    ) -> std::result::Result<String, String> {
        let book = self
            .hierarchy
            .iter()
            .find(|n| n.kind == NodeKind::Book && n.system_tag.as_deref() == Some(book_tag))
            .cloned()
            .ok_or_else(|| format!("no system book `{book_tag}` (reopen the project)"))?;
        let body = format!("= {title}\n\n{}\n", body_text.trim());

        // Overwrite an existing draft of the same title rather than piling up.
        let existing = self.hierarchy.collect_subtree(book.id).into_iter().find_map(|id| {
            self.hierarchy
                .get(id)
                .filter(|n| {
                    n.kind == NodeKind::Paragraph
                        && n.title.trim().eq_ignore_ascii_case(title)
                })
                .cloned()
        });
        if let Some(mut node) = existing {
            if let Some(rel) = node.file.clone() {
                let abs = self.layout.root.join(&rel);
                std::fs::write(&abs, body.as_bytes()).map_err(|e| e.to_string())?;
            }
            self.store
                .update_paragraph_content(&mut node, body.as_bytes())
                .map_err(|e| e.to_string())?;
            let _ = self.store.sync();
            return Ok(node.slug);
        }

        let mut node = self
            .store
            .create_node(
                &self.cfg,
                &self.hierarchy,
                NodeKind::Paragraph,
                title,
                Some(&book),
                None,
                crate::store::InsertPosition::End,
            )
            .map_err(|e| e.to_string())?;
        if let Some(rel) = node.file.clone() {
            let abs = self.layout.root.join(&rel);
            std::fs::write(&abs, body.as_bytes()).map_err(|e| e.to_string())?;
            self.store
                .update_paragraph_content(&mut node, body.as_bytes())
                .map_err(|e| e.to_string())?;
        }
        let _ = self.store.sync();
        Ok(node.slug)
    }

    /// Scroll handler for the BookInfo modal — mirrors
    /// `credits_handle_key`. Renderer clamps scroll to the actual line
    /// count.
    fn book_info_handle_key(&mut self, key: KeyEvent) -> bool {
        let Modal::BookInfo { scroll } = &mut self.modal else {
            return false;
        };
        match key.code {
            KeyCode::Up => {
                *scroll = scroll.saturating_sub(1);
                true
            }
            KeyCode::Down => {
                *scroll = scroll.saturating_add(1);
                true
            }
            KeyCode::PageUp => {
                *scroll = scroll.saturating_sub(10);
                true
            }
            KeyCode::PageDown => {
                *scroll = scroll.saturating_add(10);
                true
            }
            KeyCode::Home => {
                *scroll = 0;
                true
            }
            KeyCode::End => {
                *scroll = usize::MAX / 2;
                true
            }
            _ => false,
        }
    }

    /// Resolve the "current book" the user is inside: prefer the book
    /// containing the open paragraph (if any), otherwise the book
    /// containing the tree cursor. Returns the cloned node so the
    /// caller can drop the temporary `Hierarchy` it loaded.
    ///
    /// "Current book" walks `parent_id` up to the root: for a node at
    /// any depth this lands on the root Book; for a Book node it
    /// returns the node itself. Returns `None` only when the project
    /// is completely empty (no books seeded yet).
    fn current_book_node(&self, hierarchy: &Hierarchy) -> Option<Node> {
        let start_id = self
            .opened
            .as_ref()
            .map(|d| d.id)
            .or_else(|| self.rows.get(self.tree_cursor).map(|(id, _)| *id))?;

        let mut current_id = start_id;
        loop {
            let node = hierarchy.get(current_id)?;
            match node.parent_id {
                None => return Some(node.clone()),
                Some(pid) => current_id = pid,
            }
        }
    }

    fn build_book_info_lines(&self) -> Vec<Line<'static>> {
        let bold = Style::default().add_modifier(Modifier::BOLD);
        let dim = Style::default().add_modifier(Modifier::DIM);
        let label_color = Style::default().fg(Color::Cyan);

        let mut out: Vec<Line<'static>> = Vec::new();
        out.push(Line::from(""));

        let Ok(hierarchy) = Hierarchy::load(&self.store) else {
            out.push(Line::from(Span::styled(
                "  (could not load hierarchy)".to_string(),
                dim,
            )));
            return out;
        };

        let Some(book) = self.current_book_node(&hierarchy) else {
            out.push(Line::from(Span::styled(
                "  No book selected. Move the tree cursor onto a book \
                 (or any node inside one) and press Ctrl+B I again."
                    .to_string(),
                dim,
            )));
            return out;
        };

        out.push(Line::from(vec![
            Span::styled("  Book: ", label_color),
            Span::styled(book.title.clone(), bold),
            Span::styled(
                format!("   ({})", book.slug),
                dim,
            ),
        ]));
        if let Some(tag) = book.system_tag.as_deref() {
            out.push(Line::from(Span::styled(
                format!("    system book · tag={tag}"),
                dim,
            )));
        }
        out.push(Line::from(""));

        // 1. Paths.
        let backup_dir =
            crate::store::default_user_backup_dir(&self.layout.root);
        let artefacts_root = self.store.resolve_artefacts_dir(&self.cfg);
        let artefacts_book = artefacts_root.join(&book.slug);

        out.push(Line::from(Span::styled(
            "  Paths".to_string(),
            label_color.add_modifier(Modifier::BOLD),
        )));
        out.push(Line::from(format!("    backups:    {}", backup_dir.display())));
        out.push(Line::from(format!(
            "    artefacts:  {}",
            artefacts_book.display()
        )));
        out.push(Line::from(""));

        // 2. Stats.
        let stats = compute_book_stats(&hierarchy, &book, &self.layout.root);
        out.push(Line::from(Span::styled(
            "  Structure".to_string(),
            label_color.add_modifier(Modifier::BOLD),
        )));
        out.push(Line::from(format!(
            "    chapters:     {}",
            stats.chapters
        )));
        out.push(Line::from(format!(
            "    subchapters:  {}",
            stats.subchapters
        )));
        out.push(Line::from(format!(
            "    paragraphs:   {}",
            stats.paragraphs
        )));
        out.push(Line::from(""));

        out.push(Line::from(Span::styled(
            "  Prose".to_string(),
            label_color.add_modifier(Modifier::BOLD),
        )));
        out.push(Line::from(format!(
            "    sentences:    {}",
            stats.sentences
        )));
        out.push(Line::from(format!(
            "    words:        {}",
            stats.words
        )));
        // 250 wpm — a standard adult silent-reading speed for prose
        // (educational references typically quote 200–300 wpm). Round
        // up to whole minutes so humantime doesn't print sub-second
        // precision for an estimate that's never that precise.
        let read_pretty = if stats.words == 0 {
            "< 1m".to_string()
        } else {
            let minutes = ((stats.words as f64) / 250.0).ceil() as u64;
            humantime::format_duration(std::time::Duration::from_secs(
                minutes.max(1) * 60,
            ))
            .to_string()
        };
        out.push(Line::from(format!(
            "    reading time: {read_pretty}  (at 250 words/min)"
        )));
        out.push(Line::from(""));

        // 3. PDF status.
        let pdf_path = artefacts_book.join(format!("{}.pdf", book.slug));
        out.push(Line::from(Span::styled(
            "  Rendered PDF".to_string(),
            label_color.add_modifier(Modifier::BOLD),
        )));
        out.push(Line::from(format!(
            "    expected:   {}",
            pdf_path.display()
        )));
        match std::fs::metadata(&pdf_path) {
            Ok(meta) => {
                let created = meta
                    .created()
                    .or_else(|_| meta.modified())
                    .ok()
                    .and_then(|t| {
                        std::time::SystemTime::now()
                            .duration_since(t)
                            .ok()
                    });
                let age = match created {
                    Some(d) => format_age_humantime(d),
                    None => "(timestamp unavailable)".to_string(),
                };
                let size_kb = meta.len() / 1024;
                out.push(Line::from(vec![
                    Span::raw("    status:     "),
                    Span::styled(
                        "present".to_string(),
                        Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
                    ),
                    Span::raw(format!("  ({size_kb} KiB)")),
                ]));
                out.push(Line::from(format!("    created:    {age} ago")));
            }
            Err(_) => {
                out.push(Line::from(vec![
                    Span::raw("    status:     "),
                    Span::styled(
                        "missing".to_string(),
                        Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
                    ),
                    Span::styled(
                        "   (render the book to create it)".to_string(),
                        dim,
                    ),
                ]));
            }
        }
        out.push(Line::from(""));
        out
    }

    /// Ctrl+B L — open the LLM provider picker. Empty providers map
    /// yields a status-bar diagnostic instead of an empty modal.
    fn open_llm_picker(&mut self) {
        if self.cfg.llm.providers.is_empty() {
            self.status =
                "No LLM providers configured in inkhaven.hjson — add at least one under `llm.providers`."
                    .into();
            return;
        }
        let providers: Vec<String> = self.cfg.llm.providers.keys().cloned().collect();
        // Position the cursor on the currently-active default so Enter
        // is a confirm rather than a switch.
        let cursor = providers
            .iter()
            .position(|p| p == &self.cfg.llm.default)
            .unwrap_or(0);
        let initial_default = self.cfg.llm.default.clone();
        self.modal = Modal::LlmPicker {
            providers,
            cursor,
            initial_default,
        };
        self.status = "Switch LLM provider · ↑↓ / Enter to switch · Esc to cancel".into();
    }

    fn llm_picker_handle_key(&mut self, key: KeyEvent) -> bool {
        let Modal::LlmPicker {
            providers, cursor, ..
        } = &mut self.modal
        else {
            return false;
        };
        let total = providers.len();
        match key.code {
            KeyCode::Up => {
                if *cursor > 0 {
                    *cursor -= 1;
                }
                true
            }
            KeyCode::Down => {
                if *cursor + 1 < total {
                    *cursor += 1;
                }
                true
            }
            KeyCode::Home => {
                *cursor = 0;
                true
            }
            KeyCode::End => {
                *cursor = total.saturating_sub(1);
                true
            }
            KeyCode::Enter => {
                self.commit_llm_picker();
                true
            }
            _ => false,
        }
    }

    fn commit_llm_picker(&mut self) {
        let (chosen, initial_default) = match &self.modal {
            Modal::LlmPicker {
                providers,
                cursor,
                initial_default,
            } => (
                providers.get(*cursor).cloned(),
                initial_default.clone(),
            ),
            _ => return,
        };
        let Some(chosen) = chosen else {
            self.modal = Modal::None;
            return;
        };

        // No-op early-out: picking the same provider just closes the
        // modal without rewriting the file.
        if chosen == initial_default {
            self.modal = Modal::None;
            self.status = format!("LLM provider unchanged · still `{chosen}`");
            return;
        }

        // Persist to inkhaven.hjson with a targeted text edit so user
        // comments + the rest of the config survive the rewrite.
        let config_path = self.layout.config_path();
        let raw = match std::fs::read_to_string(&config_path) {
            Ok(s) => s,
            Err(e) => {
                self.status =
                    format!("LLM switch aborted: read {}: {e}", config_path.display());
                return;
            }
        };
        let updated = match set_llm_default_in_hjson(&raw, &chosen) {
            Ok(s) => s,
            Err(reason) => {
                self.status = format!(
                    "LLM switch aborted: can't rewrite {}: {reason}",
                    config_path.display()
                );
                return;
            }
        };
        if let Err(e) = std::fs::write(&config_path, &updated) {
            self.status = format!("LLM switch aborted: write {}: {e}", config_path.display());
            return;
        }

        // Update the live config + AiClient so subsequent prompts use
        // the new provider without restarting.
        self.cfg.llm.default = chosen.clone();
        match AiClient::from_config(&self.cfg.llm) {
            Ok(ai) => self.ai = ai,
            Err(e) => {
                // The file is already on disk so the next startup will
                // honour the new default — surface the error so the
                // user knows the in-memory client wasn't refreshed.
                self.status = format!(
                    "switched to `{chosen}` on disk, but couldn't refresh in-memory client: {e}"
                );
                self.modal = Modal::None;
                return;
            }
        }

        self.modal = Modal::None;
        self.status = format!(
            "LLM provider switched to `{chosen}` · saved to {}",
            config_path.display()
        );
    }

    /// 1.2.7+ — capture a paragraph's full state into the
    /// kill-ring before delete_subtree drops it. Pushes to
    /// the front; trims to KILL_RING_CAP. Skipped silently
    /// when the file body can't be read (which still permits
    /// the delete to proceed).
    fn stash_deleted_paragraph(&mut self, node: &Node) {
        // Find the sibling that comes immediately BEFORE the
        // about-to-be-deleted node so we can re-insert at
        // the same position via InsertPosition::After.
        let siblings = self.hierarchy.children_of(node.parent_id);
        let pos = siblings.iter().position(|s| s.id == node.id);
        let anchor_id = match pos {
            Some(0) => None, // first child — restore at end via fallback
            Some(i) => siblings.get(i - 1).map(|n| n.id),
            None => None,
        };
        let content = node
            .file
            .as_ref()
            .map(|rel| self.layout.root.join(rel))
            .and_then(|abs| std::fs::read(&abs).ok())
            .unwrap_or_default();
        self.kill_ring.push_front(DeletedParagraphStash {
            parent_id: node.parent_id,
            anchor_id,
            title: node.title.clone(),
            slug: node.slug.clone(),
            content,
            tags: node.tags.clone(),
            linked_paragraphs: node.linked_paragraphs.clone(),
            status: node.status.clone(),
            target_words: node.target_words,
            content_type: node.content_type.clone(),
            event: node.event.clone(),
        });
        while self.kill_ring.len() > KILL_RING_CAP {
            self.kill_ring.pop_back();
        }
    }

    /// 1.2.7+ — Ctrl+B U. Pop the front of the kill-ring
    /// (= most-recent delete); re-create the paragraph at
    /// its original position; restore body + metadata. The
    /// entry is consumed on success; on failure it's
    /// reinserted so the user can retry.
    fn undo_last_delete(&mut self) {
        let Some(stash) = self.kill_ring.pop_front() else {
            self.status = "undo: kill-ring is empty".into();
            return;
        };
        let parent = stash
            .parent_id
            .and_then(|id| self.hierarchy.get(id).cloned());
        let position = match stash.anchor_id {
            Some(id) if self.hierarchy.get(id).is_some() => {
                crate::store::InsertPosition::After(id)
            }
            _ => crate::store::InsertPosition::End,
        };
        let created = match self.store.create_node(
            &self.cfg,
            &self.hierarchy,
            NodeKind::Paragraph,
            &stash.title,
            parent.as_ref(),
            Some(&stash.slug),
            position,
        ) {
            Ok(n) => n,
            Err(e) => {
                // Re-stash to the front so the user can
                // retry after fixing whatever rejected the
                // create.
                self.kill_ring.push_front(stash);
                self.status = format!("undo: create_node failed: {e}");
                return;
            }
        };
        // Write the body to disk.
        if let Some(rel) = created.file.as_ref() {
            let abs = self.layout.root.join(rel);
            if let Err(e) = std::fs::write(&abs, &stash.content) {
                self.status = format!(
                    "undo: paragraph created at `{}` but body write failed: {e}",
                    created.slug
                );
                self.reload_hierarchy();
                return;
            }
        }
        // Restore the metadata (tags, linked_paragraphs,
        // status, target_words, content_type, event).
        let mut updated = created.clone();
        updated.tags = stash.tags;
        updated.linked_paragraphs = stash.linked_paragraphs;
        updated.status = stash.status;
        updated.target_words = stash.target_words;
        if stash.content_type.is_some() {
            updated.content_type = stash.content_type;
        }
        updated.event = stash.event;
        updated.modified_at = chrono::Utc::now();
        crate::store::reconcile_event_orphan_tag(&mut updated);
        if let Err(e) = self
            .store
            .raw()
            .update_metadata(updated.id, updated.to_json())
        {
            tracing::warn!(target: "inkhaven::undo",
                "metadata restore failed for {}: {e}", updated.id);
        }
        self.reload_hierarchy();
        self.status = format!(
            "↺ restored `{}` (new uuid {} — cross-refs to old uuid stay broken)",
            updated.title,
            updated.id.simple()
        );
    }

    /// 1.2.8+ — `Ctrl+Z o` / `Ctrl+Z O` toggle the shell
    /// pane.  `fresh=true` drops the cached engine + turn
    /// buffer first.  No-op (with status hint) when
    /// `shell.enabled = false` in HJSON.
    /// 1.2.8+ — open `<project>/inkhaven.hjson` in a
    /// full-screen modal editor.  Reads the current file
    /// from disk fresh on every open (so external edits
    /// since process launch are visible), seeds a
    /// `tui-textarea` with the bytes, and routes Ctrl+S,
    /// Esc, and editing keys via `hjson_editor_handle_key`.
    /// When the file is missing, we open with an empty
    /// buffer + a status hint — saving will create the file.
    /// 1.2.9+ — Ctrl+B S action: read the open paragraph
    /// aloud via the OS TTS engine.  Dispatch order:
    ///
    ///   1. No paragraph open → status hint.
    ///   2. Empty / whitespace-only body → status hint.
    ///   3. `editor.tts.enabled = false` → friendly
    ///      "TTS disabled" modal pointing at HJSON.
    ///   4. Lazy-init the engine.  On failure, cache the
    ///      error string and show the "TTS unavailable"
    ///      modal so we don't pay engine-init cost on
    ///      every keystroke.
    ///   5. Apply HJSON voice (substring match against
    ///      installed voices; prefer "Enhanced" /
    ///      "Premium" variants when present) and speed
    ///      (multiplier over `normal_rate`, clamped to
    ///      engine bounds).
    ///   6. Spawn speech with `interrupt = true` so a
    ///      second Ctrl+B S during playback restarts on
    ///      the new paragraph cleanly.
    ///   7. Open the playback modal.  The render loop
    ///      polls `is_speaking()` each frame and closes
    ///      the modal when speech ends.
    fn tts_read_paragraph(&mut self) {
        // ── 1+2 — open paragraph + non-empty body ────
        let source: String = match self.opened.as_ref() {
            Some(doc) => doc.textarea.lines().join("\n"),
            None => {
                self.status = "TTS: no paragraph open".into();
                return;
            }
        };
        let body = strip_leading_typst_heading(&source);
        let body = body.trim().to_string();
        if body.is_empty() {
            self.status = "TTS: paragraph is empty".into();
            return;
        }

        // ── 3 — feature gate ────────────────────────
        if !self.cfg.editor.tts.enabled {
            self.modal = Modal::TtsUnavailable {
                title: " TTS disabled ".into(),
                reason: format!(
                    "Read-aloud (Ctrl+B S) is disabled in this project.\n\n\
                     Enable it by adding to inkhaven.hjson:\n\n  \
                     editor: {{ tts: {{ enabled: true }} }}\n\n\
                     Default voice is `Milena` (Russian female; ships free \
                     with macOS after a one-time language download via \
                     System Settings → Accessibility → Spoken Content)."
                ),
            };
            return;
        }

        // ── 4 — engine readiness ────────────────────
        if let Err(reason) = self.tts.is_ready() {
            self.modal = Modal::TtsUnavailable {
                title: " TTS unavailable ".into(),
                reason: format!(
                    "{reason}\n\n\
                     Active backend: {backend}.  1.2.17 adds the Piper \
                     neural backend (T.2+) so non-macOS hosts will gain a \
                     working option; until then `tts.engine = \"auto\"` \
                     falls back to macOS `say` on this host.",
                    backend = self.tts.label(),
                ),
            };
            return;
        }

        // ── 5 — pick voice + speak ──────────────────
        let cfg = &self.cfg.editor.tts;
        let voice_resolved = self.tts.resolve_voice(&cfg.voice);
        let voice_label = voice_resolved
            .clone()
            .unwrap_or_else(|| "engine default".to_string());
        let voice_arg = voice_resolved.unwrap_or_default();
        // Convert speed multiplier to words-per-minute.
        // 180 wpm is the canonical "normal" reading rate;
        // multiplying by the HJSON speed lands us in the
        // engine's expected range.  Clamp to [80, 400] —
        // the System backend (`say`) accepts the full
        // range, but speech is unintelligible outside it.
        let wpm = ((180.0 * cfg.speed.max(0.1)).round() as i32)
            .clamp(80, 400) as u16;

        if let Err(e) = self.tts.speak(&body, &voice_arg, Some(wpm)) {
            self.modal = Modal::TtsUnavailable {
                title: " TTS error ".into(),
                reason: format!(
                    "Couldn't start TTS playback:\n\n  {e}\n\n\
                     Active backend: {backend}.  Check the diagnostic \
                     above for the exact cause — common reasons include \
                     a missing `say` binary (macOS) or a Piper voice \
                     that hasn't finished downloading.",
                    backend = self.tts.label(),
                ),
            };
            return;
        }

        // ── 6 — playback modal ─────────────────────
        let preview: String = body.chars().take(80).collect();
        self.modal = Modal::TtsPlayback {
            started_at: std::time::Instant::now(),
            preview,
            voice_label,
        };
    }

    /// 1.2.9+ — close the playback modal when the `say`
    /// subprocess exits.  Called each render frame so
    /// playback ending naturally closes the modal
    /// without the user pressing anything.
    pub(super) fn tts_poll_playback(&mut self) {
        if !matches!(self.modal, Modal::TtsPlayback { .. }) {
            return;
        }
        if !self.tts.is_speaking() {
            self.modal = Modal::None;
            self.status = "TTS: finished".into();
        }
    }

    /// 1.2.9+ — stop in-flight playback + close the
    /// playback modal.  Called when the user hits any
    /// key while the playback modal is open.
    fn tts_stop_playback(&mut self) {
        self.tts.stop();
        self.modal = Modal::None;
        self.status = "TTS: stopped".into();
    }

    /// 1.2.9+ — Ctrl+B Shift+F action: flip the
    /// session-local style-warnings toggle.  Three
    /// states cycle:
    ///   None  → defer to HJSON default (initial state)
    ///   true  → force overlays ON regardless of HJSON
    ///   false → force overlays OFF regardless of HJSON
    /// The chord only steps between true / false once
    /// the user has touched it; the None state is
    /// initial-only.  Status line names the new effective
    /// state so the user doesn't have to look.
    fn toggle_style_warnings(&mut self) {
        let new_state = match self.style_warnings_toggle {
            None => Some(!self.cfg.editor.style_warnings.enabled),
            Some(true) => Some(false),
            Some(false) => Some(true),
        };
        self.style_warnings_toggle = new_state;
        let effective = new_state.unwrap_or(self.cfg.editor.style_warnings.enabled);
        self.status = if effective {
            format!(
                "style warnings: ON · language={} · filter-words enabled",
                self.cfg.language,
            )
        } else {
            "style warnings: OFF".into()
        };
    }

    /// 1.2.20+ C.1.b — flip the live echo overlay.  Same
    /// three-state cycle as `toggle_style_warnings`.
    fn toggle_echo_overlay(&mut self) {
        let new_state = match self.echo_overlay_toggle {
            None => Some(!self.cfg.editor.echo_overlay),
            Some(true) => Some(false),
            Some(false) => Some(true),
        };
        self.echo_overlay_toggle = new_state;
        let effective = new_state.unwrap_or(self.cfg.editor.echo_overlay);
        // Force a recompute on the next refresh regardless
        // of the content-hash skip (the toggle itself isn't
        // part of that key).
        self.echo_overlay_last_key = None;
        if !effective {
            self.echo_overlay_stems.clear();
        }
        self.status = if effective {
            "echo overlay: ON · underlining words echoing near this paragraph".into()
        } else {
            "echo overlay: OFF".into()
        };
    }

    /// 1.2.20+ C.1.b — effective echo-overlay state
    /// (session toggle over the HJSON default).
    fn echo_overlay_active(&self) -> bool {
        self.echo_overlay_toggle
            .unwrap_or(self.cfg.editor.echo_overlay)
    }

    /// The nearest `Chapter` ancestor of the open
    /// paragraph (its own id if it somehow is a chapter).
    fn open_paragraph_chapter(&self) -> Option<Uuid> {
        use crate::store::NodeKind;
        let open = self.opened.as_ref()?;
        let node = self.hierarchy.get(open.id)?;
        if node.kind == NodeKind::Chapter {
            return Some(node.id);
        }
        self.hierarchy
            .ancestors(node)
            .into_iter()
            .find(|a| a.kind == NodeKind::Chapter)
            .map(|a| a.id)
    }

    /// Every paragraph under `chapter_id` as
    /// `(id, plain_text)` in reading order, read from the
    /// bdslib store (the live source the editor edits).
    fn gather_chapter_paragraphs(&self, chapter_id: Uuid) -> Vec<(Uuid, String)> {
        use crate::store::NodeKind;
        let mut out = Vec::new();
        for id in self.hierarchy.collect_subtree(chapter_id) {
            let Some(p) = self.hierarchy.get(id) else { continue };
            if p.kind != NodeKind::Paragraph {
                continue;
            }
            let text = self
                .store
                .get_content(id)
                .ok()
                .flatten()
                .and_then(|b| String::from_utf8(b).ok())
                .unwrap_or_default();
            out.push((id, crate::audiobook::typst_to_plain(&text)));
        }
        out
    }

    /// 1.2.20+ C.1.b — recompute the set of stems echoing
    /// near the open paragraph.  Cheap when nothing
    /// changed (content-hash skip); re-gathers the
    /// chapter's sibling paragraphs only when the open
    /// paragraph changes.  Called once per main-loop
    /// iteration, before draw.
    fn refresh_echo_overlay(&mut self) {
        if !self.echo_overlay_active() {
            if !self.echo_overlay_stems.is_empty() {
                self.echo_overlay_stems.clear();
            }
            self.echo_overlay_chapter = None;
            self.echo_overlay_last_key = None;
            return;
        }
        let Some(open) = self.opened.as_ref() else {
            self.echo_overlay_stems.clear();
            self.echo_overlay_last_key = None;
            return;
        };
        let open_id = open.id;
        let open_raw = open.textarea.lines().join("\n");

        // Skip the whole pass when neither the open
        // paragraph nor its text has changed since last time.
        let key = {
            use std::hash::{Hash, Hasher};
            let mut h = std::collections::hash_map::DefaultHasher::new();
            open_id.hash(&mut h);
            open_raw.hash(&mut h);
            h.finish()
        };
        if self.echo_overlay_last_key == Some((open_id, key)) {
            return;
        }

        let Some(chapter_id) = self.open_paragraph_chapter() else {
            self.echo_overlay_stems.clear();
            self.echo_overlay_last_key = Some((open_id, key));
            return;
        };

        // Re-gather sibling paragraphs only when the open
        // paragraph changed (one store read each); reuse
        // across keystrokes within the same paragraph.
        let need_gather = self
            .echo_overlay_chapter
            .as_ref()
            .map_or(true, |(cached_open, _)| *cached_open != open_id);
        if need_gather {
            let paras = self.gather_chapter_paragraphs(chapter_id);
            self.echo_overlay_chapter = Some((open_id, paras));
        }
        let Some((_, paras)) = self.echo_overlay_chapter.as_ref() else {
            return;
        };

        // Substitute the live (plain) open-paragraph text at
        // its slot so the overlay tracks unsaved edits.
        let open_plain = crate::audiobook::typst_to_plain(&open_raw);
        let mut texts: Vec<String> = Vec::with_capacity(paras.len());
        let mut open_index: Option<usize> = None; // 1-based
        for (i, (id, plain)) in paras.iter().enumerate() {
            if *id == open_id {
                open_index = Some(i + 1);
                texts.push(open_plain.clone());
            } else {
                texts.push(plain.clone());
            }
        }
        let Some(open_index) = open_index else {
            self.echo_overlay_stems.clear();
            self.echo_overlay_last_key = Some((open_id, key));
            return;
        };

        let language = if self.cfg.language.trim().is_empty() {
            "english"
        } else {
            self.cfg.language.as_str()
        };
        let echo_cfg = crate::echo::EchoConfig {
            window: self.cfg.editor.echo_window.max(1),
            min_repeats: self.cfg.editor.echo_min_repeats.max(2),
            max_global: self.cfg.editor.echo_max_global.max(1),
            ..crate::echo::EchoConfig::default()
        };
        let findings =
            crate::echo::detect_echoes(&texts, language, None, &echo_cfg);
        self.echo_overlay_stems = findings
            .into_iter()
            .filter(|f| open_index >= f.para_start && open_index <= f.para_end)
            .map(|f| f.stem)
            .collect();
        self.echo_overlay_last_key = Some((open_id, key));
    }

    /// 1.2.9+ — true when the POV / character chip
    /// should be painted on the status bar.  Session
    /// override (set via `Ctrl+B Shift+P`) wins; falls
    /// back to `editor.pov_chip_enabled` in HJSON.
    fn pov_chip_effective(&self) -> bool {
        match self.pov_chip_toggle {
            Some(v) => v,
            None => self.cfg.editor.pov_chip_enabled,
        }
    }

    /// 1.2.9+ — Ctrl+B Shift+P action: flip the
    /// session-local POV-chip toggle.  Three-state
    /// cycle identical to `toggle_style_warnings`.
    fn toggle_pov_chip(&mut self) {
        let new_state = match self.pov_chip_toggle {
            None => Some(!self.cfg.editor.pov_chip_enabled),
            Some(true) => Some(false),
            Some(false) => Some(true),
        };
        self.pov_chip_toggle = new_state;
        let effective = self.pov_chip_effective();
        self.status = if effective {
            "POV chip: ON · status bar shows character + supporting cast".into()
        } else {
            "POV chip: OFF".into()
        };
    }

    /// 1.2.9+ — build the styled spans for the POV
    /// character chip.  Empty Vec when the chip is
    /// disabled, no paragraph is open, the lexicon
    /// is empty, or no character names are mentioned
    /// 1.2.15+ Phase H.1 — span list for the
    /// background-health chip.  Empty when the
    /// monitor is disabled or hasn't fired yet
    /// (chip state defaults to `Hidden`).  Colour:
    /// green/Clean, amber/Repaired, yellow/Warning,
    /// red/Error.  Glyph is the single-char accent
    /// from `ChipState::glyph()`.
    pub(crate) fn health_chip_spans(&self) -> Vec<ratatui::text::Span<'_>> {
        use ratatui::style::{Color, Modifier, Style};
        use ratatui::text::Span;
        let glyph = self.health_chip.glyph();
        if glyph.is_empty() {
            return Vec::new();
        }
        let (bg, fg) = match self.health_chip {
            crate::health::ChipState::Clean => (Color::Green, Color::Black),
            crate::health::ChipState::Repaired => (Color::LightYellow, Color::Black),
            // 1.2.16+ Phase P.1 — soft-warning
            // amber tier.  Distinct from
            // `Repaired` (which uses
            // LightYellow) and the hard
            // `Warning` (Yellow) so all three
            // amber-ish chips are visually
            // separable in the status bar.
            crate::health::ChipState::Info => (Color::Magenta, Color::White),
            crate::health::ChipState::Warning => (Color::Yellow, Color::Black),
            crate::health::ChipState::Error => (Color::Red, Color::White),
            crate::health::ChipState::Hidden => return Vec::new(),
        };
        vec![
            Span::styled(
                format!(" {glyph} "),
                Style::default().bg(bg).fg(fg).add_modifier(Modifier::BOLD),
            ),
            Span::raw(" "),
        ]
    }

    /// 1.2.16+ Phase A.5 — worldbuilding glossary
    /// chip.  Renders a compact density summary
    /// `[gloss: 12C·5P·3A]` showing the cumulative
    /// count of Characters / Places / Artefacts
    /// system-book entries.  Useful sanity check:
    /// 30 chapters in with only 4 named characters
    /// flags a thin cast; 50 named places across
    /// a 200-paragraph manuscript flags a thicket
    /// the reader might struggle with.
    ///
    /// Hidden when `editor.show_glossary_chip` is
    /// false in HJSON (default true) or when all
    /// three counts are zero (fresh project).
    pub(crate) fn glossary_chip_spans(&self) -> Vec<ratatui::text::Span<'_>> {
        use ratatui::style::{Color, Modifier, Style};
        use ratatui::text::Span;
        if !self.cfg.editor.show_glossary_chip {
            return Vec::new();
        }
        let (chars, places, artefacts) = glossary_counts(&self.hierarchy);
        if chars == 0 && places == 0 && artefacts == 0 {
            return Vec::new();
        }
        let label = format!(" {chars}C·{places}P·{artefacts}A ");
        vec![
            Span::styled(
                label,
                Style::default()
                    .bg(Color::DarkGray)
                    .fg(Color::White)
                    .add_modifier(Modifier::DIM),
            ),
            Span::raw(" "),
        ]
    }

    /// 1.2.21+ FF.6 — Facts chip (`⚑<N>`): the Facts-book entry count,
    /// so the world's invariants are visible at a glance.  Gated by
    /// `editor.show_facts_chip`; auto-hides on an empty Facts book.
    pub(crate) fn facts_chip_spans(&self) -> Vec<ratatui::text::Span<'_>> {
        use ratatui::style::{Color, Modifier, Style};
        use ratatui::text::Span;
        if !self.cfg.editor.show_facts_chip {
            return Vec::new();
        }
        let n = self.facts_paragraph_ids().len();
        if n == 0 {
            return Vec::new();
        }
        vec![
            Span::styled(
                format!(" ⚑{n} "),
                Style::default()
                    .bg(Color::DarkGray)
                    .fg(Color::White)
                    .add_modifier(Modifier::DIM),
            ),
            Span::raw(" "),
        ]
    }

    /// 1.2.18+ R.3 — reading-time chip for the current
    /// book.  Shows `📖 <remaining> / <total>` at
    /// `editor.reading_wpm`, where remaining is from the
    /// open paragraph to the book's end.  Hidden when
    /// `editor.reading_time_chip` is false (default) or
    /// no paragraph is open.  Cheap: one O(n) walk of
    /// the current book's paragraph subtree (the I.1.5
    /// children index keeps it linear).
    pub(crate) fn reading_time_chip_spans(&self) -> Vec<ratatui::text::Span<'_>> {
        use ratatui::style::{Color, Modifier, Style};
        use ratatui::text::Span;
        if !self.cfg.editor.reading_time_chip {
            return Vec::new();
        }
        let Some(doc) = self.opened.as_ref() else {
            return Vec::new();
        };
        let Some(bt) = crate::tui::reading_time::compute(
            &self.hierarchy,
            doc.id,
            self.cfg.editor.reading_wpm,
        ) else {
            return Vec::new();
        };
        if bt.total_words == 0 {
            return Vec::new();
        }
        let label = format!(
            " 📖 {} / {} ",
            crate::tui::reading_time::fmt_compact(bt.remaining_secs),
            crate::tui::reading_time::fmt_compact(bt.total_secs),
        );
        vec![
            Span::styled(
                label,
                Style::default()
                    .bg(Color::DarkGray)
                    .fg(Color::White)
                    .add_modifier(Modifier::DIM),
            ),
            Span::raw(" "),
        ]
    }

    /// 1.3.12 DEEP-1 — a spinner chip shown while a background job (the deep
    /// AI refresh) runs. Driven purely by `bg_job` state, so the indicator
    /// stays put no matter what else writes the status line.
    pub(crate) fn bg_job_chip_spans(&self) -> Vec<ratatui::text::Span<'_>> {
        use ratatui::style::{Color, Modifier, Style};
        use ratatui::text::Span;
        let Some(job) = self.bg_job.as_ref() else {
            return Vec::new();
        };
        vec![
            Span::styled(
                format!(" ⟳ {} ", job.label),
                Style::default()
                    .bg(Color::Magenta)
                    .fg(Color::Black)
                    .add_modifier(Modifier::BOLD),
            ),
            Span::raw(" "),
        ]
    }

    /// in the open paragraph.  The render loop in
    /// `draw_status` splices the result into the
    /// status-bar span list after the focus chip.
    pub(crate) fn pov_chip_spans(&self) -> Vec<ratatui::text::Span<'_>> {
        use ratatui::style::{Modifier, Style};
        use ratatui::text::Span;
        if !self.pov_chip_effective() {
            return Vec::new();
        }
        let Some(doc) = self.opened.as_ref() else {
            return Vec::new();
        };
        let lines: Vec<String> = doc
            .textarea
            .lines()
            .iter()
            .map(|s| s.to_string())
            .collect();
        let chip = match crate::tui::pov_tracker::compute_pov_chip(
            &self.lexicon, &lines,
        ) {
            Some(c) => c,
            None => return Vec::new(),
        };
        let mut spans = Vec::with_capacity(3);
        spans.push(Span::raw(" "));
        // Theme-driven so users can retune the chip
        // against their terminal palette.  Default
        // bg/fg are explicit RGB to dodge the named-
        // colour-mapping trap that washed out the
        // original `Color::Magenta` on Catppuccin.
        spans.push(Span::styled(
            format!(" POV: {} ", chip.pov),
            Style::default()
                .bg(self.theme.pov_chip_bg)
                .fg(self.theme.pov_chip_fg)
                .add_modifier(Modifier::BOLD),
        ));
        if !chip.supporting.is_empty() {
            spans.push(Span::styled(
                format!(" +{}", chip.supporting.join(", ")),
                Style::default().add_modifier(Modifier::DIM),
            ));
        }
        spans
    }

    /// 1.2.9+ — Ctrl+B < / Ctrl+B > scene-break
    /// navigation.  `dir = -1` walks backward to the
    /// previous scene-break line; `dir = 1` walks
    /// forward to the next.  Moves the textarea
    /// cursor to column 0 of the matching line and
    /// recenters the viewport via the existing
    /// CursorMove::Jump machinery.
    fn scene_break_jump(&mut self, dir: i32) {
        let Some(doc) = self.opened.as_mut() else {
            self.status = "scene break: no paragraph open".into();
            return;
        };
        let (cur_row, _cur_col) = doc.textarea.cursor();
        let lines = doc.textarea.lines();
        let n = lines.len();
        let target: Option<usize> = if dir > 0 {
            // Walk forward from cur_row + 1.
            (cur_row + 1..n).find(|&r| is_scene_break(&lines[r]))
        } else {
            // Walk backward from cur_row - 1.
            (0..cur_row).rev().find(|&r| is_scene_break(&lines[r]))
        };
        match target {
            Some(row) => {
                doc.textarea.move_cursor(
                    tui_textarea::CursorMove::Jump(row as u16, 0),
                );
                self.status = format!(
                    "scene break: line {} of {}",
                    row + 1,
                    n,
                );
            }
            None => {
                self.status = if dir > 0 {
                    "scene break: no break below"
                } else {
                    "scene break: no break above"
                }
                .into();
            }
        }
    }

    /// 1.2.9+ — Ctrl+B Shift+G action: open the
    /// writing-streak heatmap modal.  Pulls the last
    /// 91 days of project-wide word deltas from the
    /// progress store, computes current + longest
    /// streak in the window, and seeds the modal with
    /// the data so the render path doesn't re-query.
    /// 1.2.15+ Phase D.3 — open the project-wide
    /// doctor modal.  Runs the same scan as the
    /// CLI `inkhaven doctor --scan`; presents the
    /// findings as a cursor-driven panel with
    /// `r`/`R` repairs + `Esc` close.  Synchronous
    /// scan for now — for typical projects this
    /// returns in milliseconds.
    /// 1.2.16+ Phase A.2 — open the manuscript
    /// intelligence dashboard.  Synchronously
    /// gathers a `JournalSnapshot` from progress
    /// cache + hierarchy + sidecars + threads
    /// book.  Snapshot is captured once at modal
    /// open; the user re-opens for fresh numbers.
    fn open_journal(&mut self) {
        let snapshot = crate::tui::journal::compute_snapshot(
            self.progress_cache.as_ref(),
            &self.hierarchy,
            &self.layout.root,
            self.cfg.project.word_count_goal,
            &self.cfg.project.target_date,
        );
        self.modal = super::modal::Modal::Journal {
            snapshot,
            scroll: 0,
            last_status: None,
        };
        self.status = "journal: ↑↓ scroll · e export to markdown · Esc closes".into();
    }

    /// 1.2.16+ Phase A.2 — modal-local key handler
    /// for the journal.  Returns true if the modal
    /// consumed the key.
    pub(super) fn handle_journal_key(&mut self, key: crossterm::event::KeyEvent) -> bool {
        use crossterm::event::KeyCode;
        let super::modal::Modal::Journal { snapshot, scroll, last_status } =
            &mut self.modal
        else {
            return false;
        };
        match key.code {
            KeyCode::Esc => {
                self.modal = super::modal::Modal::None;
                true
            }
            KeyCode::Up => {
                if *scroll > 0 {
                    *scroll -= 1;
                }
                true
            }
            KeyCode::Down => {
                *scroll = scroll.saturating_add(1);
                true
            }
            KeyCode::PageUp => {
                *scroll = scroll.saturating_sub(10);
                true
            }
            KeyCode::PageDown => {
                *scroll = scroll.saturating_add(10);
                true
            }
            KeyCode::Home => {
                *scroll = 0;
                true
            }
            KeyCode::Char('e') => {
                let body = crate::tui::journal::to_markdown(snapshot);
                let stem = format!(
                    "journal-{}.md",
                    snapshot.generated_at.format("%Y%m%dT%H%M%S"),
                );
                let path = self.layout.root.join(&stem);
                match crate::io_atomic::write(&path, body.as_bytes()) {
                    Ok(()) => {
                        let msg = format!("journal: exported to {}", path.display());
                        *last_status = Some(msg.clone());
                        self.status = msg;
                    }
                    Err(e) => {
                        let msg = format!("journal export failed: {e}");
                        *last_status = Some(msg.clone());
                        self.status = msg;
                    }
                }
                true
            }
            _ => false,
        }
    }

    /// 1.2.18+ R.4 — open the reader-pace preview for
    /// the open paragraph.  Strips the leading heading +
    /// markup (the same plain-text pass the audiobook
    /// export uses) so the teleprompter reads clean
    /// prose, then starts the highlight advancing at
    /// `editor.reading_wpm`.
    fn open_reader_pace(&mut self) {
        let Some(doc) = self.opened.as_ref() else {
            self.status = "reader pace: no paragraph open".into();
            return;
        };
        let body = doc.textarea.lines().join("\n");
        let plain = crate::audiobook::typst_to_plain(&body);
        let words = super::reader_pace::split_words(&plain);
        if words.is_empty() {
            self.status = "reader pace: paragraph is empty".into();
            return;
        }
        let wpm = self.cfg.editor.reading_wpm.max(1);
        let count = words.len();
        self.modal = super::modal::Modal::ReaderPace {
            words,
            started_at: std::time::Instant::now(),
            accumulated: std::time::Duration::ZERO,
            paused: false,
            wpm,
        };
        self.status = format!(
            "reader pace: {count} words @ {wpm} wpm · Space pause · ←→ step · r restart · Esc close",
        );
    }

    /// 1.2.18+ R.4 — current highlight word index for the
    /// reader-pace modal, derived from elapsed reading
    /// time.  Pure read of the modal state; returns
    /// `(index, total)`.  `None` when the modal isn't
    /// open.
    pub(super) fn reader_pace_index(&self) -> Option<(usize, usize)> {
        let super::modal::Modal::ReaderPace {
            words,
            started_at,
            accumulated,
            paused,
            wpm,
        } = &self.modal
        else {
            return None;
        };
        let elapsed = if *paused {
            *accumulated
        } else {
            *accumulated + started_at.elapsed()
        };
        let idx = super::reader_pace::word_index_at(
            elapsed.as_secs_f64(),
            *wpm,
            words.len(),
        );
        Some((idx, words.len()))
    }

    /// 1.2.18+ R.4 — true when the reader-pace modal is
    /// open + actively advancing (not paused).  Drives
    /// the main loop's fast-tick so the highlight
    /// animates smoothly.
    pub(super) fn reader_pace_playing(&self) -> bool {
        matches!(
            &self.modal,
            super::modal::Modal::ReaderPace { paused: false, .. },
        )
    }

    /// 1.2.18+ R.4 — modal-local key handler.  Returns
    /// true when the key was consumed.
    pub(super) fn handle_reader_pace_key(
        &mut self,
        key: crossterm::event::KeyEvent,
    ) -> bool {
        use crossterm::event::KeyCode;
        let super::modal::Modal::ReaderPace {
            words,
            started_at,
            accumulated,
            paused,
            wpm,
        } = &mut self.modal
        else {
            return false;
        };
        match key.code {
            KeyCode::Esc => {
                self.modal = super::modal::Modal::None;
                self.status = "reader pace: closed".into();
                true
            }
            KeyCode::Char(' ') => {
                // Toggle pause: freeze / resume the
                // accumulated reading time.
                if *paused {
                    *started_at = std::time::Instant::now();
                    *paused = false;
                } else {
                    *accumulated += started_at.elapsed();
                    *paused = true;
                }
                true
            }
            KeyCode::Char('r') => {
                *accumulated = std::time::Duration::ZERO;
                *started_at = std::time::Instant::now();
                *paused = false;
                true
            }
            KeyCode::Right | KeyCode::Left => {
                // Step the highlight by ±1 word: recompute
                // `accumulated` so the new elapsed lands on
                // the target index, then pause.
                let total = words.len();
                let wps = (*wpm as f64 / 60.0).max(0.0001);
                let cur_elapsed = if *paused {
                    accumulated.as_secs_f64()
                } else {
                    accumulated.as_secs_f64() + started_at.elapsed().as_secs_f64()
                };
                let cur_idx = super::reader_pace::word_index_at(
                    cur_elapsed, *wpm, total,
                );
                let next_idx = if matches!(key.code, KeyCode::Right) {
                    (cur_idx + 1).min(total)
                } else {
                    cur_idx.saturating_sub(1)
                };
                // Elapsed that lands mid-word `next_idx`
                // (add half a word so floor() stays put).
                let target_secs = (next_idx as f64 + 0.5) / wps;
                *accumulated = std::time::Duration::from_secs_f64(
                    target_secs.max(0.0),
                );
                *paused = true;
                true
            }
            _ => false,
        }
    }

    /// 1.2.17+ T.6 — open the Piper TTS voice picker.
    /// Resolves the voices directory under the project
    /// root via path_safety, loads the catalog (T.3
    /// freshness-first; stale + dir-only fallbacks),
    /// builds the picker state.  Open is decoupled
    /// from the active engine — even on macOS with
    /// the System backend running, users can use the
    /// picker to browse + download Piper voices.
    fn open_tts_voice_picker(&mut self) {
        use super::piper::catalog::Catalog;
        use super::piper::download::curl_get_json;
        use super::voice_picker::TtsVoicePickerState;
        let cfg = &self.cfg.editor.tts;
        let voices_dir = match crate::path_safety::resolve_within_str(
            &self.layout.root,
            cfg.voices_dir.trim(),
        ) {
            Ok(p) => p,
            Err(e) => {
                self.status = format!(
                    "voice picker: tts.voices_dir invalid ({e})",
                );
                return;
            }
        };
        let _ = std::fs::create_dir_all(&voices_dir);
        let ttl = std::time::Duration::from_secs(
            cfg.catalog_ttl_hours as u64 * 3600,
        );
        let state = match Catalog::load(
            &voices_dir,
            &cfg.catalog_url,
            ttl,
            curl_get_json,
        ) {
            Ok(catalog) => TtsVoicePickerState::from_catalog(&catalog, &voices_dir),
            Err(e) => TtsVoicePickerState::from_dir_only(
                &voices_dir,
                e.to_user_message(),
            ),
        };
        let header = if state.entries.is_empty() {
            "voice picker: empty (no catalog + no downloaded voices)".to_string()
        } else if state.catalog_failed.is_some() {
            format!(
                "voice picker (offline · {} voices on disk): ↑↓ select · / filter · Esc closes",
                state.entries.len(),
            )
        } else if state.catalog_stale {
            format!(
                "voice picker (stale catalog · {} voices): ↑↓ select · / filter · Enter download/use · d remove · Esc closes",
                state.entries.len(),
            )
        } else {
            format!(
                "voice picker ({} voices): ↑↓ select · / filter · Enter download/use · d remove · Esc closes",
                state.entries.len(),
            )
        };
        self.modal = super::modal::Modal::TtsVoicePicker { state };
        self.status = header;
    }

    /// 1.2.17+ T.6 — modal-local key handler for the
    /// voice picker.  Returns true when the key was
    /// consumed.
    pub(super) fn handle_tts_voice_picker_key(
        &mut self,
        key: crossterm::event::KeyEvent,
    ) -> bool {
        use crossterm::event::KeyCode;
        let super::modal::Modal::TtsVoicePicker { state } = &mut self.modal
        else {
            return false;
        };
        match key.code {
            KeyCode::Esc => {
                self.modal = super::modal::Modal::None;
                self.status = "voice picker: closed".into();
                true
            }
            KeyCode::Up => {
                state.move_up();
                true
            }
            KeyCode::Down => {
                state.move_down();
                true
            }
            KeyCode::Home => {
                state.move_to_top();
                true
            }
            KeyCode::End => {
                state.move_to_bottom();
                true
            }
            KeyCode::PageUp => {
                for _ in 0..10 {
                    state.move_up();
                }
                true
            }
            KeyCode::PageDown => {
                for _ in 0..10 {
                    state.move_down();
                }
                true
            }
            KeyCode::Backspace => {
                state.pop_filter_char();
                true
            }
            KeyCode::Char('d') if state.filter.is_empty() => {
                // Remove the selected voice's files +
                // update LRU.  Only acts when a filter
                // isn't in progress so 'd' doesn't
                // accidentally collide with type-to-
                // filter.
                self.tts_voice_picker_remove();
                true
            }
            KeyCode::Enter => {
                self.tts_voice_picker_commit();
                true
            }
            KeyCode::Char(c) => {
                state.push_filter_char(c);
                true
            }
            _ => false,
        }
    }

    /// 1.2.17+ T.6 — Enter handler: download (if
    /// absent) + set the runtime voice.  Blocks the UI
    /// for the duration — a background-task version is
    /// a T.6.b polish item.
    fn tts_voice_picker_commit(&mut self) {
        let (voice_key, downloaded) = {
            let super::modal::Modal::TtsVoicePicker { state } = &self.modal
            else {
                return;
            };
            let Some(entry) = state.selected_entry() else {
                self.status = "voice picker: nothing selected".into();
                return;
            };
            (entry.key.clone(), entry.downloaded)
        };
        if downloaded {
            // Just set the runtime voice + close.
            self.cfg.editor.tts.voice = voice_key.clone();
            self.modal = super::modal::Modal::None;
            self.status = format!(
                "voice picker: set runtime voice to `{voice_key}` (use `inkhaven config` to persist)",
            );
            return;
        }
        // Download path — blocking.  Surface progress
        // in the picker's status before the call so the
        // user sees a sane "downloading..." line if the
        // terminal redraws between key handlers.
        if let super::modal::Modal::TtsVoicePicker { state } = &mut self.modal {
            state.status = format!(
                "downloading `{voice_key}` — please wait...",
            );
        }
        let result = self.tts_voice_picker_download(&voice_key);
        let super::modal::Modal::TtsVoicePicker { state } = &mut self.modal
        else {
            return;
        };
        match result {
            Ok(()) => {
                state.refresh_downloaded(
                    &self.layout.root.join(
                        self.cfg.editor.tts.voices_dir.trim(),
                    ),
                );
                self.cfg.editor.tts.voice = voice_key.clone();
                state.status =
                    format!("downloaded `{voice_key}` · runtime voice set");
                self.status = format!(
                    "voice picker: downloaded `{voice_key}` · runtime voice set",
                );
            }
            Err(reason) => {
                state.status =
                    format!("download failed: {reason}");
                self.status =
                    format!("voice picker: download failed: {reason}");
            }
        }
    }

    /// Blocking download of `voice_key` via the T.4
    /// pipeline.  Reuses the catalog loaded by
    /// open_tts_voice_picker — re-load if it's not
    /// present (defensive).
    fn tts_voice_picker_download(
        &self,
        voice_key: &str,
    ) -> Result<(), String> {
        use super::piper::catalog::Catalog;
        use super::piper::download::{curl_get_json, curl_get_to_file};
        use super::piper::voice::ensure_voice_downloaded;
        let cfg = &self.cfg.editor.tts;
        let voices_dir = crate::path_safety::resolve_within_str(
            &self.layout.root,
            cfg.voices_dir.trim(),
        )
        .map_err(|e| format!("voices_dir invalid: {e}"))?;
        let ttl = std::time::Duration::from_secs(
            cfg.catalog_ttl_hours as u64 * 3600,
        );
        let catalog = Catalog::load(
            &voices_dir,
            &cfg.catalog_url,
            ttl,
            curl_get_json,
        )
        .map_err(|e| e.to_user_message())?;
        let voice = catalog.voice(voice_key).ok_or_else(|| {
            format!("voice `{voice_key}` not found in catalog")
        })?;
        ensure_voice_downloaded(
            voice,
            &voices_dir,
            &self.layout.root,
            cfg.cache_max_voices,
            cfg.auto_gitignore,
            curl_get_to_file,
            |_progress| {},
        )
        .map(|_files| ())
        .map_err(|e| e.to_user_message())
    }

    /// 1.2.17+ T.6 — `d` handler: remove the selected
    /// voice's files + drop it from the LRU index.
    fn tts_voice_picker_remove(&mut self) {
        use super::piper::lru::LruIndex;
        let (voice_key, was_downloaded) = {
            let super::modal::Modal::TtsVoicePicker { state } = &self.modal
            else {
                return;
            };
            let Some(entry) = state.selected_entry() else {
                self.status = "voice picker: nothing selected".into();
                return;
            };
            (entry.key.clone(), entry.downloaded)
        };
        if !was_downloaded {
            self.status = format!(
                "voice picker: `{voice_key}` is not downloaded",
            );
            return;
        }
        let voices_dir = match crate::path_safety::resolve_within_str(
            &self.layout.root,
            self.cfg.editor.tts.voices_dir.trim(),
        ) {
            Ok(p) => p,
            Err(e) => {
                self.status = format!(
                    "voice picker: voices_dir invalid ({e})",
                );
                return;
            }
        };
        let onnx = voices_dir.join(format!("{voice_key}.onnx"));
        let onnx_json = voices_dir.join(format!("{voice_key}.onnx.json"));
        let _ = std::fs::remove_file(&onnx);
        let _ = std::fs::remove_file(&onnx_json);
        // Drop from LRU.
        let mut idx = LruIndex::load(&voices_dir);
        // No public "remove" on LruIndex — we rebuild
        // by touching every surviving key except this
        // one; cheap enough at cache_max_voices = 5.
        let kept: Vec<(String, std::time::Duration)> = idx
            .entries()
            .iter()
            .filter(|e| e.voice_key != voice_key)
            .map(|e| (e.voice_key.clone(), e.last_touched))
            .collect();
        idx = LruIndex::default();
        for (key, when) in kept {
            idx.touch(
                &key,
                std::time::SystemTime::UNIX_EPOCH + when,
            );
        }
        let _ = idx.save(&voices_dir);

        let super::modal::Modal::TtsVoicePicker { state } = &mut self.modal
        else {
            return;
        };
        state.refresh_downloaded(&voices_dir);
        state.status = format!("removed `{voice_key}`");
        self.status =
            format!("voice picker: removed `{voice_key}`");
    }

    fn open_doctor_panel(&mut self) {
        match crate::cli::doctor_scan::scan_project(&self.layout.root, None) {
            Ok(report) => {
                let count = report.findings.len();
                self.modal = super::modal::Modal::DoctorPanel {
                    findings: report.findings,
                    cursor: 0,
                    scroll: 0,
                    last_status: None,
                };
                self.status = if count == 0 {
                    "doctor: project is clean".into()
                } else {
                    format!("doctor: {count} finding(s) — r/R to repair · Esc closes")
                };
            }
            Err(e) => {
                self.status = format!("doctor scan failed: {e:#}");
            }
        }
    }

    /// 1.2.15+ Phase D.3 — handle a keystroke
    /// inside the doctor modal.  Returns true if
    /// the modal consumed the key.
    pub(super) fn handle_doctor_panel_key(&mut self, key: crossterm::event::KeyEvent) -> bool {
        use crossterm::event::KeyCode;
        let super::modal::Modal::DoctorPanel { findings, cursor, scroll, last_status } =
            &mut self.modal
        else {
            return false;
        };
        match key.code {
            KeyCode::Esc => {
                self.modal = super::modal::Modal::None;
                true
            }
            KeyCode::Up => {
                if *cursor > 0 {
                    *cursor -= 1;
                    if *cursor < *scroll {
                        *scroll = *cursor;
                    }
                }
                true
            }
            KeyCode::Down => {
                if !findings.is_empty() && *cursor + 1 < findings.len() {
                    *cursor += 1;
                }
                true
            }
            KeyCode::Char('r') => {
                if findings.is_empty() {
                    return true;
                }
                let cur = *cursor;
                let Some(finding) = findings.get(cur).cloned() else {
                    return true;
                };
                let project = self.layout.root.clone();
                let outcome = crate::cli::doctor_scan::apply_fix(&project, &finding);
                crate::cli::doctor_scan::log_fix(&project, &finding, &outcome);
                let summary = match &outcome {
                    Ok(s) => format!("repaired: {s}"),
                    Err(e) => format!("repair failed: {e:#}"),
                };
                *last_status = Some(summary.clone());
                self.status = summary;
                // Remove the finding from the list
                // on success so the user sees the
                // table shrink as they go.
                if outcome.is_ok() {
                    findings.remove(cur);
                    if *cursor >= findings.len() && *cursor > 0 {
                        *cursor -= 1;
                    }
                }
                true
            }
            KeyCode::Char('R') => {
                // Apply every remaining finding.
                let project = self.layout.root.clone();
                let mut applied = 0usize;
                let mut errors = 0usize;
                let snapshot = findings.clone();
                findings.clear();
                for f in snapshot.iter() {
                    let outcome = crate::cli::doctor_scan::apply_fix(&project, f);
                    crate::cli::doctor_scan::log_fix(&project, f, &outcome);
                    if outcome.is_ok() {
                        applied += 1;
                    } else {
                        errors += 1;
                        findings.push(f.clone());
                    }
                }
                let msg =
                    format!("repair all: {applied} applied, {errors} error(s)");
                *cursor = 0;
                *scroll = 0;
                *last_status = Some(msg.clone());
                self.status = msg;
                true
            }
            _ => false,
        }
    }

    fn open_writing_streak_heatmap(&mut self) {
        let project_total = self
            .progress_cache
            .as_ref()
            .map(|s| s.project.total_words)
            .unwrap_or(0);
        let daily_words = crate::progress::daily_words(project_total, 91);
        if daily_words.is_empty() {
            self.status =
                "streak: no progress data yet — write a paragraph then save".into();
            return;
        }
        // Current streak: count consecutive trailing days
        // with > 0 words.  Stops at the first 0.  We treat
        // today's 0 as a non-break (the user might still
        // be about to write), but yesterday + earlier
        // zeros break the streak.
        let mut streak_days: u32 = 0;
        let n = daily_words.len();
        // Skip today if it's 0 (grace).
        let mut idx = n.saturating_sub(1);
        if daily_words.get(idx).copied().unwrap_or(0) == 0 && idx > 0 {
            idx -= 1;
        }
        loop {
            if daily_words.get(idx).copied().unwrap_or(0) > 0 {
                streak_days += 1;
                if idx == 0 {
                    break;
                }
                idx -= 1;
            } else {
                break;
            }
        }
        // Longest run of consecutive >0 days in the window.
        let mut longest: u32 = 0;
        let mut current: u32 = 0;
        for w in &daily_words {
            if *w > 0 {
                current += 1;
                longest = longest.max(current);
            } else {
                current = 0;
            }
        }
        // Today's date in (y, m, d) — chrono's UTC date.
        let today = chrono::Utc::now().date_naive();
        use chrono::Datelike;
        let today_ymd = (today.year(), today.month(), today.day());
        self.modal = Modal::WritingStreakHeatmap {
            daily_words,
            streak_days,
            longest_streak: longest,
            today_ymd,
        };
        self.status = format!(
            "streak: {streak_days}-day current · {longest} longest (91-day window) · Esc closes"
        );
    }

    /// 1.2.9+ — Ctrl+B Shift+H action: analyse the
    /// open paragraph's sentence rhythm and open the
    /// `SentenceRhythm` modal.  Splits prose into
    /// sentences, tallies word counts, computes
    /// mean / stdev / coefficient of variation, and
    /// maps the CV to a verdict.  Falls through to
    /// a status warning when no paragraph is open or
    /// the body has fewer than 3 sentences (rhythm
    /// can't be meaningfully judged below that).
    fn open_sentence_rhythm(&mut self) {
        let Some(doc) = self.opened.as_ref() else {
            self.status = "rhythm: no paragraph open".into();
            return;
        };
        let lines: Vec<String> = doc
            .textarea
            .lines()
            .iter()
            .map(|s| s.to_string())
            .collect();
        // Strip the leading typst heading so the
        // title doesn't count as a sentence.
        let body = strip_leading_typst_heading(&lines.join("\n"));
        let body_lines: Vec<String> =
            body.split('\n').map(|s| s.to_string()).collect();
        let stats = crate::tui::sentence_rhythm::analyse(&body_lines);
        if stats.lengths.is_empty() {
            self.status = "rhythm: paragraph empty".into();
            return;
        }
        self.status = format!(
            "rhythm: {} sentences · CV {:.2} · {} · Esc closes",
            stats.lengths.len(),
            stats.cv,
            stats.verdict.label(),
        );
        self.modal = crate::tui::modal::Modal::SentenceRhythm {
            stats,
            scroll: 0,
        };
    }

    /// 1.2.9+ — key dispatch for the
    /// `SentenceRhythm` modal.  Scroll the per-
    /// sentence list with ↑↓ / PgUp/PgDn / Home/End;
    /// any other key closes.
    fn sentence_rhythm_handle_key(&mut self, key: crossterm::event::KeyEvent) {
        use crossterm::event::KeyCode;
        // 1.2.11+ — the rhythm gauge is the natural
        // launchpad for the rhythm-rewrite chord
        // (Ctrl+B Shift+M).  Intercept the meta
        // prefix here so the chord works from
        // inside the modal: dismiss the gauge,
        // arm meta-pending, and let the next key
        // flow through the normal meta-prefix
        // dispatch at the top of `handle_key`.
        // This generalises — any meta-action is
        // reachable from inside the gauge, not
        // just the rewrite.
        if self.keymap.meta_prefix.matches(&key) {
            self.modal = Modal::None;
            self.meta_pending = true;
            self.status = super::keybind::read().meta_hint(self.focus);
            return;
        }
        let n = match &self.modal {
            Modal::SentenceRhythm { stats, .. } => stats.samples.len(),
            _ => return,
        };
        let Modal::SentenceRhythm { scroll, .. } = &mut self.modal else {
            return;
        };
        match key.code {
            KeyCode::Esc => {
                self.modal = Modal::None;
                self.status = "rhythm: closed".into();
            }
            KeyCode::Down => {
                if *scroll + 1 < n {
                    *scroll += 1;
                }
            }
            KeyCode::Up => {
                *scroll = scroll.saturating_sub(1);
            }
            KeyCode::PageDown => {
                *scroll = (*scroll + 10).min(n.saturating_sub(1));
            }
            KeyCode::PageUp => {
                *scroll = scroll.saturating_sub(10);
            }
            KeyCode::Home => {
                *scroll = 0;
            }
            KeyCode::End => {
                *scroll = n.saturating_sub(1);
            }
            _ => {
                self.modal = Modal::None;
                self.status = "rhythm: closed".into();
            }
        }
    }

    /// 1.2.9+ — Ctrl+B Shift+L action: build a
    /// project-wide concordance and open the
    /// `Concordance` modal.  Walks every paragraph in
    /// the in-memory hierarchy, loads its body via
    /// `Store::get_content`, strips the leading typst
    /// heading line, and feeds the body rows into the
    /// concordance builder.  Uses the project's
    /// `language` to pick the Snowball stemmer + the
    /// stop-word set (same plumbing as the repeated-
    /// phrase detector — no second list to tune).
    /// Cheap at literary scale (well under a second
    /// for a 100k-word manuscript); the build runs
    /// synchronously on the UI thread, then the modal
    /// just slices the cached entries on every key.
    fn open_concordance(&mut self) {
        use crate::tui::concordance::{build, ParagraphInput, SortMode};
        let mut bodies: Vec<(String, Vec<String>)> = Vec::new();
        for node in self.hierarchy.iter() {
            if node.kind != NodeKind::Paragraph {
                continue;
            }
            // 1.2.11+ — skip paragraphs that live under any
            // system book (Prompts, Characters, Places,
            // Lore, Help, Notes, Artefacts, Typst,
            // Scripts, etc).  Two reasons: (1) those are
            // metadata / scaffolding, not prose, so they
            // dilute the lexical signal — the keybind
            // description promises "the words actually
            // carrying the prose's weight".  (2) System-
            // book paragraphs are typically stored only in
            // bdslib (the prompts editor never writes to
            // disk), so the Enter-to-jump path would
            // fail with `read …: No such file or
            // directory` when `load_paragraph` looks for
            // the .typ file.  Identified by walking the
            // ancestor chain; ANY ancestor with a
            // `system_tag` disqualifies the descendant.
            if self
                .hierarchy
                .ancestors(node)
                .iter()
                .any(|a| a.system_tag.is_some())
            {
                continue;
            }
            let slug_path = self.hierarchy.slug_path(node);
            let raw = match self.store.get_content(node.id) {
                Ok(Some(bytes)) => bytes,
                _ => continue,
            };
            let text = match std::str::from_utf8(&raw) {
                Ok(s) => strip_leading_typst_heading(s),
                Err(_) => continue,
            };
            let lines: Vec<String> =
                text.split('\n').map(|s| s.to_string()).collect();
            bodies.push((slug_path, lines));
        }
        if bodies.is_empty() {
            self.status =
                "concordance: project has no paragraphs to analyse".into();
            return;
        }
        let inputs: Vec<ParagraphInput<'_>> = bodies
            .iter()
            .map(|(slug, lines)| ParagraphInput {
                slug_path: slug.clone(),
                lines,
            })
            .collect();
        let data = build(
            &self.cfg.editor.style_warnings.repeated_phrases,
            &self.cfg.language,
            &inputs,
        );
        if data.entries.is_empty() {
            self.status = format!(
                "concordance: {} paragraphs scanned, no lexical content (all stop-words?)",
                data.paragraphs_scanned
            );
            return;
        }
        let visible: Vec<usize> = (0..data.entries.len()).collect();
        let stats = format!(
            "concordance: {} distinct stems · {} tokens · {} paragraphs · Esc closes",
            data.distinct_words, data.total_tokens, data.paragraphs_scanned
        );
        self.status = stats;
        self.modal = Modal::Concordance {
            data,
            filter: crate::tui::input::TextInput::new(),
            cursor: 0,
            scroll: 0,
            sort: SortMode::Count,
            visible,
        };
    }

    /// 1.2.9+ — recompute the cached `visible` list on
    /// the `Concordance` modal.  Called whenever the
    /// filter text or sort mode changes.  Filter
    /// semantics: case-insensitive substring match
    /// against the headword OR any kept variant — so
    /// typing `walk` surfaces an entry whose headword
    /// is `walked` but whose variants include `walk`.
    fn concordance_refilter(&mut self) {
        use crate::tui::concordance::sort_in_place;
        let Modal::Concordance {
            data,
            filter,
            cursor,
            scroll,
            sort,
            visible,
        } = &mut self.modal
        else {
            return;
        };
        sort_in_place(&mut data.entries, *sort);
        let needle = filter.as_str().trim().to_lowercase();
        visible.clear();
        for (i, entry) in data.entries.iter().enumerate() {
            if needle.is_empty()
                || entry.headword.contains(&needle)
                || entry.stem.contains(&needle)
                || entry.variants.iter().any(|v| v.contains(&needle))
            {
                visible.push(i);
            }
        }
        if visible.is_empty() {
            *cursor = 0;
            *scroll = 0;
        } else if *cursor >= visible.len() {
            *cursor = visible.len() - 1;
        }
        // Keep cursor on-screen — render clamps scroll
        // against the visible-row height, so this is
        // just a coarse precaution.
        if *cursor < *scroll {
            *scroll = *cursor;
        }
    }

    /// 1.2.9+ — key dispatch for the `Concordance`
    /// modal.  Single-pane list with an inline filter
    /// input — typing characters narrows the list,
    /// arrow keys move the selection, `Ctrl+S` toggles
    /// the sort mode, Esc closes.  1.2.11+ — Enter on
    /// a selected row jumps to the first sample's
    /// source paragraph at the corresponding editor
    /// line; see `concordance_jump_to_selection`.
    fn concordance_handle_key(&mut self, key: crossterm::event::KeyEvent) {
        use crossterm::event::{KeyCode, KeyModifiers};
        // Esc always closes — even mid-filter.
        if matches!(key.code, KeyCode::Esc) {
            self.modal = Modal::None;
            self.status = "concordance: closed".into();
            return;
        }
        // Ctrl+S toggles sort mode.  Plain `s` falls
        // through to the filter input so headwords
        // beginning with `s` stay typeable.
        if matches!(key.code, KeyCode::Char('s'))
            && key.modifiers.contains(KeyModifiers::CONTROL)
        {
            if let Modal::Concordance { sort, .. } = &mut self.modal {
                *sort = sort.toggle();
            }
            self.concordance_refilter();
            if let Modal::Concordance { sort, .. } = &self.modal {
                self.status = format!("concordance: sort = {}", sort.label());
            }
            return;
        }
        // 1.2.11+ — Enter on a row jumps to the source
        // paragraph at the first sample's line.  Plain
        // Enter only — Ctrl+Enter and friends pass
        // through to the filter input (which today
        // doesn't recognise them either, so they no-op,
        // which is what we want).
        if matches!(key.code, KeyCode::Enter)
            && !key.modifiers.contains(KeyModifiers::CONTROL)
            && !key.modifiers.contains(KeyModifiers::ALT)
        {
            self.concordance_jump_to_selection();
            return;
        }
        // Arrow keys + PgUp/PgDn move the selection.
        match key.code {
            KeyCode::Down => {
                if let Modal::Concordance { cursor, visible, .. } = &mut self.modal {
                    if !visible.is_empty() && *cursor + 1 < visible.len() {
                        *cursor += 1;
                    }
                }
                return;
            }
            KeyCode::Up => {
                if let Modal::Concordance { cursor, .. } = &mut self.modal {
                    if *cursor > 0 {
                        *cursor -= 1;
                    }
                }
                return;
            }
            KeyCode::PageDown => {
                if let Modal::Concordance { cursor, visible, .. } = &mut self.modal {
                    if !visible.is_empty() {
                        *cursor = (*cursor + 10).min(visible.len() - 1);
                    }
                }
                return;
            }
            KeyCode::PageUp => {
                if let Modal::Concordance { cursor, .. } = &mut self.modal {
                    *cursor = cursor.saturating_sub(10);
                }
                return;
            }
            KeyCode::Home => {
                if let Modal::Concordance { cursor, .. } = &mut self.modal {
                    *cursor = 0;
                }
                return;
            }
            KeyCode::End => {
                if let Modal::Concordance { cursor, visible, .. } = &mut self.modal {
                    if !visible.is_empty() {
                        *cursor = visible.len() - 1;
                    }
                }
                return;
            }
            _ => {}
        }
        // Filter input: typing edits the filter buffer,
        // Backspace deletes, etc.  Only ASCII-printable
        // + Unicode chars are forwarded; control
        // characters fall through to the no-op branch.
        if let Modal::Concordance { filter, .. } = &mut self.modal {
            match key.code {
                KeyCode::Backspace => filter.backspace(),
                KeyCode::Delete => filter.delete(),
                KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL)
                    && !key.modifiers.contains(KeyModifiers::ALT) =>
                {
                    filter.insert_char(c);
                }
                _ => return,
            }
        }
        self.concordance_refilter();
    }

    /// 1.2.11+ — Enter on a selected concordance row.
    /// Resolves the first sample's `slug_path` to a
    /// paragraph node via `Hierarchy::find_by_path`,
    /// loads it via `open_paragraph_by_uuid`, then
    /// positions the textarea cursor at the matching
    /// editor row.  The concordance index is built
    /// over heading-stripped bodies but the editor
    /// displays the raw paragraph (with the leading
    /// `= title` line), so we compute the head-stripped
    /// offset on the freshly loaded body and add it to
    /// the sample's 1-based `line_no`.
    fn concordance_jump_to_selection(&mut self) {
        // Pull what we need out of the modal up front
        // so the modal can be closed before
        // borrow-y operations like loading the
        // paragraph.
        let (slug_path, line_no, headword) = {
            let Modal::Concordance {
                data,
                cursor,
                visible,
                ..
            } = &self.modal
            else {
                return;
            };
            if visible.is_empty() {
                self.status = "concordance: no row selected".into();
                return;
            }
            let entry_idx = visible[*cursor];
            let entry = &data.entries[entry_idx];
            let Some(sample) = entry.samples.first() else {
                self.status = format!(
                    "concordance: `{}` has no samples — nothing to jump to",
                    entry.headword,
                );
                return;
            };
            (
                sample.slug_path.clone(),
                sample.line_no,
                entry.headword.clone(),
            )
        };
        // Resolve slug_path → node.  `find_by_path`
        // walks slug segments from the root, which is
        // the exact inverse of `slug_path` (used at
        // build time), so a round-trip always
        // resolves when the source paragraph still
        // exists.
        let target_id = match self.hierarchy.find_by_path(&slug_path) {
            Some(node) if node.kind == NodeKind::Paragraph => node.id,
            Some(_) => {
                self.status = format!(
                    "concordance: `{slug_path}` resolves to a non-paragraph node"
                );
                return;
            }
            None => {
                self.status = format!(
                    "concordance: source paragraph `{slug_path}` no longer exists"
                );
                return;
            }
        };
        // Close the modal before navigating so the
        // user lands on a fully-visible editor.
        self.modal = Modal::None;
        if let Err(e) = self.open_paragraph_by_uuid(target_id) {
            self.status = format!("concordance: couldn't open paragraph: {e}");
            return;
        }
        // Translate the 1-based line_no (counted in
        // the heading-stripped body) to an editor
        // row in the full body.  The strip removes the
        // `= heading` line plus subsequent blank
        // lines; we replay the same skip on the live
        // textarea to get the prefix offset.
        let offset = if let Some(doc) = self.opened.as_ref() {
            let lines = doc.textarea.lines();
            let mut o = 0usize;
            if lines
                .first()
                .is_some_and(|l| l.trim_start().starts_with('='))
            {
                o += 1;
                while lines.get(o).is_some_and(|l| l.trim().is_empty()) {
                    o += 1;
                }
            }
            o
        } else {
            0
        };
        let target_row = offset + line_no.saturating_sub(1);
        if let Some(doc) = self.opened.as_mut() {
            let max_row = doc.textarea.lines().len().saturating_sub(1);
            let row = target_row.min(max_row);
            doc.textarea.move_cursor(
                tui_textarea::CursorMove::Jump(row as u16, 0),
            );
        }
        self.status = format!(
            "concordance: jumped to `{headword}` at {slug_path}:{line_no}",
        );
    }

    /// 1.2.9+ — Ctrl+B Shift+R action: open a save-as
    /// path picker pre-filled with
    /// `<project>/audio/<paragraph-slug>.aiff`.  Enter
    /// commits via `commit_tts_save_as_audio`; Esc
    /// cancels.  Same enable + platform gates as the
    /// chord-driven TTS — surfaces the `TtsUnavailable`
    /// modal when TTS is disabled or the OS isn't
    /// macOS.
    fn tts_open_save_as_audio_picker(&mut self) {
        // Reuse the same enable + platform gates as
        // tts_read_paragraph.
        let source: String = match self.opened.as_ref() {
            Some(doc) => doc.textarea.lines().join("\n"),
            None => {
                self.status = "TTS save: no paragraph open".into();
                return;
            }
        };
        let body = strip_leading_typst_heading(&source);
        let body = body.trim().to_string();
        if body.is_empty() {
            self.status = "TTS save: paragraph is empty".into();
            return;
        }
        if !self.cfg.editor.tts.enabled {
            self.modal = Modal::TtsUnavailable {
                title: " TTS disabled ".into(),
                reason: format!(
                    "Read-aloud + save-as-audio are disabled in this \
                     project.\n\n\
                     Enable by adding to inkhaven.hjson:\n\n  \
                     editor: {{ tts: {{ enabled: true }} }}"
                ),
            };
            return;
        }
        if let Err(reason) = self.tts.is_ready() {
            self.modal = Modal::TtsUnavailable {
                title: " TTS unavailable ".into(),
                reason: format!(
                    "{reason}\n\nSave-as-audio routes through the same \
                     TTS engine as `Ctrl+B S`.  Active backend: {backend}. \
                     1.2.17 Piper transition (T.5) brings cross-platform \
                     save-as-audio.",
                    backend = self.tts.label(),
                ),
            };
            return;
        }
        // Default destination: <project>/audio/<slug>.aiff.
        // Slug comes from the opened doc's title (sanitised
        // for filesystem) so two paragraphs with similar
        // titles get distinct files.
        let title = self
            .opened
            .as_ref()
            .map(|d| d.title.clone())
            .unwrap_or_else(|| "paragraph".to_string());
        let slug = audio_filename_slug(&title);
        let dest = self.layout.root.join("audio").join(format!("{slug}.aiff"));
        let mut input = TextInput::new();
        for c in dest.to_string_lossy().chars() {
            input.insert_char(c);
        }
        let cfg = &self.cfg.editor.tts;
        let voice = self.tts.resolve_voice(&cfg.voice).unwrap_or_default();
        let voice_label = if voice.is_empty() {
            "engine default".to_string()
        } else {
            voice.clone()
        };
        let wpm = ((180.0 * cfg.speed.max(0.1)).round() as i32)
            .clamp(80, 400) as u16;
        let status_label = voice_label.clone();
        self.modal = Modal::TtsSaveAsAudio {
            input,
            body,
            voice,
            wpm,
            voice_label,
        };
        self.status = format!(
            "TTS save: edit path or Enter to confirm · voice={status_label} · Esc cancels",
        );
    }

    /// 1.2.9+ — commit the save-as-audio picker.  Creates
    /// the parent directory if missing; spawns
    /// `say -o <dest> -v <voice> -r <wpm>` with the
    /// paragraph text on stdin.  Blocks until the
    /// subprocess exits so the file is on disk by the
    /// time the status bar reports success — the user
    /// expects to see + open it immediately.  5-second
    /// hard cap on the wait so a stuck `say` doesn't
    /// hang the TUI.
    fn commit_tts_save_as_audio(
        &mut self,
        dest_raw: &str,
        body: &str,
        voice: &str,
        wpm: u16,
        voice_label: &str,
    ) {
        let dest = std::path::PathBuf::from(dest_raw.trim());
        // The blocking save dispatches through TtsEngine
        // so the System (`say -o`) and future Piper
        // (`piper > out.wav`) backends share the same
        // call site.  Each backend handles its own
        // parent-dir creation, subprocess lifecycle, and
        // timeout behaviour.
        match self.tts.speak_to_file_blocking(
            body,
            voice,
            Some(wpm),
            &dest,
            std::time::Duration::from_secs(30),
        ) {
            Ok(bytes) => {
                self.status = format!(
                    "TTS save: wrote {} ({} KB · voice={voice_label})",
                    dest.display(),
                    bytes / 1024,
                );
            }
            Err(e) => {
                self.status = format!("TTS save: {e}");
            }
        }
    }

    /// 1.2.9+ — speak arbitrary text through the same
    /// TTS pipeline `Ctrl+B S` uses.  Public entry point
    /// for Bund scripts (`"text" ink.tts.speak`).  Honours
    /// the `editor.tts.enabled` HJSON gate the chord does
    /// — scripts can't bypass user preference.  Returns
    /// `Ok(())` on successful spawn (the speech then plays
    /// in parallel), `Err(...)` when:
    ///   * TTS is disabled in config, OR
    ///   * the platform isn't macOS, OR
    ///   * the `/usr/bin/say` subprocess failed to spawn.
    /// Callers (the Bund word handler) surface these as a
    /// script-visible error so users get a clear reason
    /// rather than a silent no-op.
    pub(crate) fn tts_speak_string(&mut self, text: &str) -> Result<(), String> {
        let text = text.trim();
        if text.is_empty() {
            return Err("empty text".into());
        }
        self.tts.is_ready()?;
        let cfg = &self.cfg.editor.tts;
        let voice = self.tts.resolve_voice(&cfg.voice).unwrap_or_default();
        let wpm = ((180.0 * cfg.speed.max(0.1)).round() as i32)
            .clamp(80, 400) as u16;
        self.tts.speak(text, &voice, Some(wpm))
    }

    /// 1.2.9+ — speak `editor.tts.greeting` at startup.
    /// Non-blocking: spawns `say` and returns immediately.
    /// Audio plays in parallel with the main loop coming
    /// up.  Silently no-op when greeting is empty,
    /// `enabled = false`, or the platform isn't macOS.
    pub(super) fn tts_speak_greeting(&mut self) {
        let text = self.cfg.editor.tts.greeting.trim().to_string();
        if text.is_empty() {
            return;
        }
        if self.tts.is_ready().is_err() {
            return;
        }
        let cfg = &self.cfg.editor.tts;
        let voice = self.tts.resolve_voice(&cfg.voice).unwrap_or_default();
        let wpm = ((180.0 * cfg.speed.max(0.1)).round() as i32)
            .clamp(80, 400) as u16;
        let _ = self.tts.speak(&text, &voice, Some(wpm));
    }

    /// 1.2.9+ — speak `editor.tts.goodbye` at shutdown
    /// and BLOCK until the `say` subprocess exits or the
    /// 6-second safety cap fires.  Without the block,
    /// inkhaven would exit and the OS audio device's
    /// driver would terminate the speech mid-word.
    /// Polled in 50ms increments.  Silently no-op when
    /// goodbye is empty, `enabled = false`, or the
    /// platform isn't macOS.
    pub(super) fn tts_speak_goodbye_blocking(&mut self) {
        let text = self.cfg.editor.tts.goodbye.trim().to_string();
        if text.is_empty() {
            return;
        }
        if self.tts.is_ready().is_err() {
            return;
        }
        let cfg = &self.cfg.editor.tts;
        let voice = self.tts.resolve_voice(&cfg.voice).unwrap_or_default();
        let wpm = ((180.0 * cfg.speed.max(0.1)).round() as i32)
            .clamp(80, 400) as u16;
        if self.tts.speak(&text, &voice, Some(wpm)).is_err() {
            return;
        }
        // Wait for the subprocess to exit naturally.
        // Poll in 50ms increments; 6-second hard cap so a
        // user who configured a tome as their goodbye
        // doesn't hang the quit indefinitely.
        let hard_deadline = std::time::Instant::now()
            + std::time::Duration::from_secs(6);
        while std::time::Instant::now() < hard_deadline {
            if !self.tts.is_speaking() {
                break;
            }
            std::thread::sleep(std::time::Duration::from_millis(50));
        }
        // Ensure the subprocess is gone even if it
        // outran our deadline.
        self.tts.stop();
    }

    fn open_hjson_editor(&mut self) {
        let path = self.layout.config_path();
        let original_content = match std::fs::read_to_string(&path) {
            Ok(s) => s,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                self.status = format!(
                    "hjson: `{}` not found — opening empty buffer (Ctrl+S to create)",
                    path.display()
                );
                String::new()
            }
            Err(e) => {
                self.status = format!(
                    "hjson: cannot read `{}`: {e}",
                    path.display()
                );
                return;
            }
        };

        // Seed the textarea with the file's lines.  Setting
        // line[0] via TextArea::new(vec!["".into()]) and then
        // typing in lots of chars is much slower than passing
        // the full Vec up-front; do the latter.
        let lines: Vec<String> = if original_content.is_empty() {
            vec![String::new()]
        } else {
            original_content.lines().map(String::from).collect()
        };
        let mut textarea = TextArea::new(lines);
        textarea.set_line_number_style(
            Style::default().fg(self.theme.line_number_fg),
        );
        // Reuse the editor's cursor style so the visual
        // feedback feels consistent.
        textarea.set_cursor_style(
            Style::default().add_modifier(Modifier::REVERSED),
        );
        // No tabstop translation — the file is config, leave
        // tabs verbatim.
        textarea.set_hard_tab_indent(true);

        self.modal = Modal::HjsonEditor {
            textarea,
            original_content,
            path,
            restart_required: false,
            scroll_row: 0,
            scroll_col: 0,
        };
        self.status =
            "hjson: editing inkhaven.hjson · Ctrl+S save · Esc close".into();
    }

    /// 1.2.8+ — write the HJSON editor's buffer to its
    /// captured path.  When the saved bytes differ from the
    /// pre-open original, flip `restart_required` so the
    /// renderer pops the warning overlay; otherwise just
    /// surface "saved" on the status bar.
    fn save_hjson_editor(&mut self) {
        let Modal::HjsonEditor {
            textarea,
            original_content,
            path,
            restart_required,
            ..
        } = &mut self.modal
        else {
            return;
        };
        let bytes: String = textarea.lines().join("\n");
        match std::fs::write(&path, &bytes) {
            Ok(_) => {
                let changed = bytes != *original_content;
                if changed {
                    *restart_required = true;
                    // Update the baseline so a subsequent save
                    // of unchanged-since-last-save bytes
                    // doesn't keep re-firing the overlay.
                    *original_content = bytes;
                    self.status = format!(
                        "hjson: saved · restart required (any key dismisses overlay)"
                    );
                } else {
                    self.status =
                        "hjson: saved (no changes since last save)".into();
                }
            }
            Err(e) => {
                self.status =
                    format!("hjson: save failed: {e}");
            }
        }
    }

    /// 1.2.8+ — route a keystroke through the HJSON editor
    /// modal.  Mirrors the main paragraph editor's key set
    /// so muscle memory carries over verbatim.  Handles:
    ///   - Restart overlay: any key dismisses.
    ///   - Ctrl+S save; Esc close (with unsaved-edit warn).
    ///   - Arrows / Home / End / PgUp / PgDn (Shift extends
    ///     selection, plain cancels it) — routed via
    ///     CursorMove because tui-textarea's
    ///     `input_without_shortcuts` drops those keys.
    ///   - Ctrl+Home/End → top/bottom of buffer.
    ///   - Ctrl+Left/Right → word back / forward.
    ///   - Ctrl+Backspace → delete word.
    ///   - Ctrl+U undo, Ctrl+Y redo.
    ///   - Ctrl+K cut, Ctrl+C copy, Ctrl+P paste, Ctrl+A
    ///     select-all.
    ///   - Ctrl+D delete-line, Ctrl+E delete-to-EOL,
    ///     Ctrl+W delete-to-BOL.
    ///   - Everything else: textarea.input_without_shortcuts
    ///     (so plain typing + Tab + Enter + Backspace work,
    ///     but no emacs-style ctrl chord interception).
    fn hjson_editor_handle_key(&mut self, key: KeyEvent) {
        use tui_textarea::CursorMove;

        // Restart overlay is sticky-modal: any key clears it.
        if matches!(
            self.modal,
            Modal::HjsonEditor { restart_required: true, .. }
        ) {
            if let Modal::HjsonEditor { restart_required, .. } = &mut self.modal {
                *restart_required = false;
            }
            self.status = "hjson: overlay dismissed (modal still open)".into();
            return;
        }

        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
        let shift = key.modifiers.contains(KeyModifiers::SHIFT);
        let alt = key.modifiers.contains(KeyModifiers::ALT);

        // Ctrl+S — save.
        if ctrl
            && matches!(key.code, KeyCode::Char('s') | KeyCode::Char('S'))
        {
            self.save_hjson_editor();
            return;
        }

        // 1.2.12+ — Ctrl+R fires an LLM review of the
        // current buffer.  Reuses the prompts-editor
        // TUI's "reviewer LLM" pattern: the model
        // critiques the HJSON config without trying
        // to execute it.  Response streams into
        // `App.inference` so it's visible in the AI
        // pane after the modal closes (Esc).
        if ctrl
            && matches!(key.code, KeyCode::Char('r') | KeyCode::Char('R'))
        {
            self.start_hjson_review();
            return;
        }

        // Esc — close (with dirty warn).
        if matches!(key.code, KeyCode::Esc) {
            let dirty = matches!(
                &self.modal,
                Modal::HjsonEditor { textarea, original_content, .. }
                    if textarea.lines().join("\n") != *original_content
            );
            if dirty {
                self.status =
                    "hjson: closed with unsaved edits — re-open with Ctrl+B 0 to recover from disk".into();
            } else {
                self.status = "hjson: closed".into();
            }
            self.modal = Modal::None;
            return;
        }

        // Plain arrows + Home/End/PgUp/PgDn.  Shift extends
        // selection, plain cancels.  Routed explicitly
        // because input_without_shortcuts drops them.
        if !alt && !ctrl {
            let cmove = match key.code {
                KeyCode::Up => Some(CursorMove::Up),
                KeyCode::Down => Some(CursorMove::Down),
                KeyCode::Left => Some(CursorMove::Back),
                KeyCode::Right => Some(CursorMove::Forward),
                KeyCode::Home => Some(CursorMove::Head),
                KeyCode::End => Some(CursorMove::End),
                KeyCode::PageUp => Some(CursorMove::ParagraphBack),
                KeyCode::PageDown => Some(CursorMove::ParagraphForward),
                _ => None,
            };
            if let Some(cmove) = cmove {
                if let Modal::HjsonEditor { textarea, .. } = &mut self.modal {
                    if shift {
                        if textarea.selection_range().is_none() {
                            textarea.start_selection();
                        }
                    } else {
                        textarea.cancel_selection();
                    }
                    textarea.move_cursor(cmove);
                }
                return;
            }
        }

        // Ctrl+modified motion + edit chords.  Mirror the
        // main editor (Ctrl+U undo, Ctrl+Y redo, Ctrl+K/C/P
        // cut/copy/paste, Ctrl+A select-all, Ctrl+D/E/W
        // line-targeted deletes, Ctrl+Home/End top/bottom,
        // Ctrl+Left/Right word jumps, Ctrl+Backspace
        // delete-word).
        if ctrl {
            match key.code {
                KeyCode::Char('u') | KeyCode::Char('U') if !shift => {
                    if let Modal::HjsonEditor { textarea, .. } = &mut self.modal {
                        textarea.undo();
                    }
                    return;
                }
                KeyCode::Char('y') | KeyCode::Char('Y') if !shift => {
                    if let Modal::HjsonEditor { textarea, .. } = &mut self.modal {
                        textarea.redo();
                    }
                    return;
                }
                KeyCode::Char('k') | KeyCode::Char('K') if !shift => {
                    if let Modal::HjsonEditor { textarea, .. } = &mut self.modal {
                        textarea.cut();
                    }
                    return;
                }
                KeyCode::Char('c') | KeyCode::Char('C') if !shift => {
                    if let Modal::HjsonEditor { textarea, .. } = &mut self.modal {
                        textarea.copy();
                    }
                    return;
                }
                KeyCode::Char('p') | KeyCode::Char('P') if !shift => {
                    if let Modal::HjsonEditor { textarea, .. } = &mut self.modal {
                        textarea.paste();
                    }
                    return;
                }
                KeyCode::Char('a') | KeyCode::Char('A') if !shift => {
                    if let Modal::HjsonEditor { textarea, .. } = &mut self.modal {
                        textarea.select_all();
                    }
                    return;
                }
                KeyCode::Char('d') | KeyCode::Char('D') if !shift => {
                    if let Modal::HjsonEditor { textarea, .. } = &mut self.modal {
                        textarea.delete_line_by_end();
                        textarea.delete_line_by_head();
                    }
                    return;
                }
                KeyCode::Char('e') | KeyCode::Char('E') if !shift => {
                    if let Modal::HjsonEditor { textarea, .. } = &mut self.modal {
                        textarea.delete_line_by_end();
                    }
                    return;
                }
                KeyCode::Char('w') | KeyCode::Char('W') if !shift => {
                    if let Modal::HjsonEditor { textarea, .. } = &mut self.modal {
                        textarea.delete_line_by_head();
                    }
                    return;
                }
                KeyCode::Home => {
                    if let Modal::HjsonEditor { textarea, .. } = &mut self.modal {
                        textarea.move_cursor(CursorMove::Top);
                    }
                    return;
                }
                KeyCode::End => {
                    if let Modal::HjsonEditor { textarea, .. } = &mut self.modal {
                        textarea.move_cursor(CursorMove::Bottom);
                    }
                    return;
                }
                KeyCode::Left => {
                    if let Modal::HjsonEditor { textarea, .. } = &mut self.modal {
                        textarea.move_cursor(CursorMove::WordBack);
                    }
                    return;
                }
                KeyCode::Right => {
                    if let Modal::HjsonEditor { textarea, .. } = &mut self.modal {
                        textarea.move_cursor(CursorMove::WordForward);
                    }
                    return;
                }
                KeyCode::Backspace => {
                    if let Modal::HjsonEditor { textarea, .. } = &mut self.modal {
                        textarea.delete_word();
                    }
                    return;
                }
                _ => {}
            }
        }

        // Everything else: pass to textarea WITHOUT its
        // emacs-style defaults (matches the main editor).
        // This handles plain typing, Tab, Enter, Backspace,
        // Delete, and any other key not caught above.
        if let Modal::HjsonEditor { textarea, .. } = &mut self.modal {
            let input: tui_textarea::Input = key.into();
            textarea.input_without_shortcuts(input);
        }
    }

    fn open_shell_pane(&mut self, fresh: bool) {
        if !self.cfg.shell.enabled {
            self.status =
                "shell: disabled (set `shell.enabled: true` in inkhaven.hjson)".into();
            return;
        }
        // Toggle: if the pane is already open, close it
        // (engine + history persist on App).
        if matches!(self.modal, Modal::ShellPane { .. }) {
            self.modal = Modal::None;
            self.status = "shell: closed (state preserved · Ctrl+Z o to reopen)".into();
            return;
        }
        // 1.2.8+ — autosave the open buffer before yielding
        // control to the shell.  Common workflow is "write
        // a paragraph → flip to shell to inspect / compute
        // → flip back" and losing unsaved edits on shell-
        // open would surprise the user.  Failures are
        // non-fatal: save_current writes its own status
        // line on disk error.
        if self.opened.as_ref().is_some_and(|d| d.dirty) {
            let _ = self.save_current();
        }
        if fresh {
            self.shell_engine = None;
            self.shell_history.clear();
            self.shell_command_history.clear();
            // `Ctrl+Z O` fresh-start: we DON'T wipe the
            // on-disk history.  The user wants a fresh
            // engine state, not amnesia about what they
            // typed yesterday.  If they want full reset
            // they can `rm .inkhaven/shell_history.db`.
        }
        // Lazy-init the engine on first open.
        if self.shell_engine.is_none() {
            self.shell_engine = Some(
                super::shell::Engine::new(&self.layout.root)
                    .with_blocked_externals(
                        self.cfg.shell.blocked_externals.clone(),
                    ),
            );
        }
        // Lazy-init the per-project SQLite history.  Load
        // the most-recent cap commands so Up-arrow recall
        // works on the first open of every session.
        if self.shell_history_db.is_none() {
            let db = super::shell::History::open(&self.layout.root);
            // Seed the in-memory ring from disk if the
            // in-memory ring is empty (first open of this
            // session — or fresh reset above).
            if self.shell_command_history.is_empty() {
                self.shell_command_history =
                    db.load(self.cfg.shell.max_buffered_turns);
            }
            self.shell_history_db = Some(db);
        }
        self.modal = Modal::ShellPane {
            input: TextInput::new(),
            command_history_cursor: None,
            selection_mode: false,
            selection_cursor: 0,
            scroll: 0,
            show_help: false,
        };
        self.status = if fresh {
            "shell: fresh engine · type a command, Enter runs it".into()
        } else {
            "shell: open · type a command, Enter runs it · Ctrl+Z o close · Ctrl+Z h selection".into()
        };
    }

    /// 1.2.8+ — eval the user's line with a wall-clock
    /// watchdog.  Moves the engine into a worker thread,
    /// polls the result channel with the configured timeout,
    /// and recovers a fresh engine when a wedged child
    /// process (`vim` blocking on `/dev/tty`, an unknown TUI
    /// app, an infinite loop) refuses to respond to the
    /// nu-side interrupt signal.
    ///
    /// Three completion paths:
    ///   1. Worker returns within `external_timeout_secs`
    ///      → engine moved back, output forwarded verbatim.
    ///   2. Worker doesn't return within budget but DOES
    ///      respond to `signals().trigger()` within the
    ///      2-second grace window → engine moved back, output
    ///      replaced by a "timed out — engine recovered"
    ///      ShellOutput so the user knows the command was cut.
    ///   3. Worker still wedged after grace → spawn a fresh
    ///      `Engine`, abandon the worker thread (Rust will
    ///      keep it alive until it eventually exits, but it
    ///      no longer interferes with the App).  Env vars,
    ///      `def` declarations, and `cd`-mutated `$env.PWD`
    ///      are lost; the user is told so.
    fn shell_eval_with_watchdog(
        &mut self,
        line: String,
    ) -> super::shell::ShellOutput {
        let timeout_secs = self.cfg.shell.external_timeout_secs.max(1);
        let timeout = std::time::Duration::from_secs(timeout_secs);
        // Grace = how long we give the worker to honour the
        // signal after the budget runs out.  Two seconds is
        // enough for nu's tight inner loops to notice but
        // short enough that a genuinely wedged external
        // doesn't keep the user waiting forever.
        let grace = std::time::Duration::from_secs(2);

        let engine = match self.shell_engine.take() {
            Some(e) => e,
            None => {
                return super::shell::ShellOutput {
                    stdout: String::new(),
                    stderr: "engine not initialised".into(),
                    success: false,
                };
            }
        };
        // Reset the interrupt flag in case a prior watchdog
        // event left it set (we cleared it on engine move-back,
        // but a fresh engine has it set to false anyway).
        engine.reset_interrupt();
        let interrupt = engine.interrupt_handle();

        let (tx, rx) = std::sync::mpsc::channel();
        let _handle = std::thread::Builder::new()
            .name("inkhaven-shell-eval".into())
            .spawn(move || {
                let mut eng = engine;
                let out = eng.eval(&line);
                // Send back the engine + output.  Receiver may
                // have already given up (timeout path 3); the
                // send fails silently and `eng` drops with the
                // closure.
                let _ = tx.send((eng, out));
            });

        match rx.recv_timeout(timeout) {
            Ok((eng_back, out)) => {
                self.shell_engine = Some(eng_back);
                out
            }
            Err(_) => {
                // Path 2: trigger interrupt and wait grace.
                interrupt.store(true, std::sync::atomic::Ordering::Relaxed);
                match rx.recv_timeout(grace) {
                    Ok((eng_back, _stale_out)) => {
                        eng_back.reset_interrupt();
                        self.shell_engine = Some(eng_back);
                        super::shell::ShellOutput {
                            stdout: String::new(),
                            stderr: format!(
                                "timed out after {timeout_secs}s — \
                                 command was interrupted.  Engine \
                                 recovered cleanly."
                            ),
                            success: false,
                        }
                    }
                    Err(_) => {
                        // Path 3: hard timeout, worker leaked.
                        let blocked =
                            self.cfg.shell.blocked_externals.clone();
                        self.shell_engine = Some(
                            super::shell::Engine::new(&self.layout.root)
                                .with_blocked_externals(blocked),
                        );
                        super::shell::ShellOutput {
                            stdout: String::new(),
                            stderr: format!(
                                "timed out after {timeout_secs}s — \
                                 command did not respond to interrupt. \
                                 Engine has been restarted; env vars, \
                                 def declarations, and `cd` state are \
                                 lost.  Consider adding the offending \
                                 binary to `shell.blocked_externals`."
                            ),
                            success: false,
                        }
                    }
                }
            }
        }
    }

    /// 1.2.8+ — Tab completion inside the OS Shell pane.
    /// Routes the current token through `Engine::complete`
    /// (which decides command vs path context) and folds
    /// the result into the input:
    ///   - 0 matches → status "no matches"
    ///   - 1 match    → replace token with match (+ trailing
    ///                  space for commands, kept-`/` for dirs)
    ///   - N matches  → replace token with longest common
    ///                  prefix when it advances; status line
    ///                  shows up to 6 candidates
    /// No-op outside the pane (the Tab key only reaches this
    /// method via `shell_pane_handle_key`).
    fn shell_tab_complete(&mut self) {
        let (input_str, cursor_chars) = match &self.modal {
            Modal::ShellPane { input, .. } => {
                (input.as_str().to_string(), input.cursor())
            }
            _ => return,
        };
        let completion = match self.shell_engine.as_ref() {
            Some(eng) => eng.complete(&input_str, cursor_chars),
            None => return,
        };
        if completion.matches.is_empty() {
            self.status = format!(
                "shell: no matches for `{}`",
                completion.token
            );
            return;
        }

        // Decide what text to splice into the input.  For a
        // single match: the whole match.  For multiple: the
        // longest common prefix — but only if it ACTUALLY
        // advances past what the user has already typed
        // (otherwise we'd produce a no-op edit and the user
        // would think Tab is broken).
        let replacement: String = if completion.matches.len() == 1 {
            let mut r = completion.matches[0].clone();
            // Append a trailing space for completed command
            // names so the next token can start typing
            // immediately.  Directories already have a
            // trailing `/` from complete_path.
            if !r.ends_with('/') {
                r.push(' ');
            }
            r
        } else {
            let lcp = super::shell::longest_common_prefix(&completion.matches);
            if lcp.chars().count() > completion.token.chars().count() {
                lcp
            } else {
                // No advance possible — keep token unchanged,
                // just show the matches.
                completion.token.clone()
            }
        };

        // Splice the replacement into the input:
        //   prefix = input[..token_start] (chars)
        //   suffix = input[cursor..]      (chars)
        let chars: Vec<char> = input_str.chars().collect();
        let prefix: String =
            chars[..completion.token_start].iter().collect();
        let suffix: String = chars[cursor_chars..].iter().collect();
        let new_cursor_chars = prefix.chars().count()
            + replacement.chars().count();
        let new_buffer = format!("{prefix}{replacement}{suffix}");

        if let Modal::ShellPane { input, .. } = &mut self.modal {
            input.set_with_cursor(new_buffer, new_cursor_chars);
        }

        // Surface the candidate list when there are several,
        // so the user can see what else matches.
        if completion.matches.len() > 1 {
            const SHOW_CAP: usize = 6;
            let preview: Vec<&str> = completion
                .matches
                .iter()
                .take(SHOW_CAP)
                .map(String::as_str)
                .collect();
            let more = completion.matches.len().saturating_sub(SHOW_CAP);
            let suffix = if more > 0 {
                format!(" … (+{more} more)")
            } else {
                String::new()
            };
            self.status = format!(
                "shell: {} matches — {}{}",
                completion.matches.len(),
                preview.join("  "),
                suffix,
            );
        } else {
            self.status = "shell: completed".into();
        }
    }

    /// 1.2.8+ — `Ctrl+Z h` toggle history-selection mode
    /// from inside the shell pane.  No-op when invoked
    /// outside the pane (the chord lives in the bund-
    /// prefix and dispatcher routes unconditionally).
    fn toggle_shell_selection_mode(&mut self) {
        let Modal::ShellPane {
            selection_mode,
            selection_cursor,
            ..
        } = &mut self.modal
        else {
            self.status = "shell selection: only available inside the shell pane".into();
            return;
        };
        if *selection_mode {
            *selection_mode = false;
            self.status = "shell: back to normal mode".into();
        } else {
            if self.shell_history.is_empty() {
                self.status = "shell selection: nothing to select yet".into();
                return;
            }
            *selection_mode = true;
            // Land on the most-recent turn — same default as
            // AI chat selection mode.
            *selection_cursor = self.shell_history.len() - 1;
            self.status =
                "shell selection · ↑↓ pick turn · c copy · i insert · Ctrl+Z h exit".into();
        }
    }

    /// 1.2.8+ — selection-mode `c`: copy the highlighted
    /// turn's output to the system clipboard.  Includes
    /// stderr below stdout when the command failed, so the
    /// user gets full context if they paste into a bug
    /// report.  No-op when no clipboard is available
    /// (rare — usually only on minimal Linux + no X / no
    /// wayland).
    fn shell_selection_copy(&mut self) {
        let cursor = match &self.modal {
            Modal::ShellPane { selection_cursor, .. } => *selection_cursor,
            _ => return,
        };
        let Some(turn) = self.shell_history.get(cursor) else {
            self.status = "shell copy: cursor out of range".into();
            return;
        };
        let mut text = turn.stdout.clone();
        if !turn.success && !turn.stderr.is_empty() {
            if !text.is_empty() {
                text.push('\n');
            }
            text.push_str("[stderr]\n");
            text.push_str(&turn.stderr);
        }
        match self.clipboard.as_mut() {
            Some(cb) => match cb.set_text(text.clone()) {
                Ok(()) => {
                    self.status = format!(
                        "shell: copied turn `{}` output ({} chars)",
                        truncate_to_chars(&turn.command, 30),
                        text.chars().count()
                    );
                }
                Err(e) => {
                    self.status =
                        format!("shell copy: clipboard error: {e}");
                }
            },
            None => {
                self.status =
                    "shell copy: no system clipboard available".into();
            }
        }
    }

    /// 1.2.8+ — selection-mode `i`: insert the highlighted
    /// turn's stdout into the editor at cursor, wrapped in
    /// `cfg.shell.insert_template`.  `{output}` in the
    /// template is replaced with the raw output verbatim;
    /// no escaping (the default template uses a typst raw
    /// block where backticks bound the literal, so embedded
    /// quotes / backslashes survive).  Closes the shell
    /// pane after insert + refocuses the editor — same UX
    /// as AI chat selection `t`.
    fn shell_selection_insert(&mut self) {
        let cursor = match &self.modal {
            Modal::ShellPane { selection_cursor, .. } => *selection_cursor,
            _ => return,
        };
        let Some(turn) = self.shell_history.get(cursor).cloned() else {
            self.status = "shell insert: cursor out of range".into();
            return;
        };
        if self.opened.is_none() {
            self.status =
                "shell insert: no paragraph open — open one first".into();
            return;
        }
        let template = self.cfg.shell.insert_template.clone();
        let wrapped = template.replace("{output}", &turn.stdout);
        let chars = wrapped.chars().count();
        if let Some(doc) = self.opened.as_mut() {
            doc.textarea.insert_str(&wrapped);
            doc.dirty = true;
            doc.last_activity = std::time::Instant::now();
        }
        // Close the pane (engine + history persist on App).
        self.modal = Modal::None;
        self.change_focus(Focus::Editor);
        self.status = format!(
            "shell: inserted output of `{}` ({} chars wrapped)",
            truncate_to_chars(&turn.command, 30),
            chars,
        );
    }

    /// 1.2.8+ — key handler for `Modal::ShellPane`.  Routes
    /// by `selection_mode`: false → line-editor + Up/Down
    /// command-history recall + Enter eval + Esc close;
    /// true → ↑↓ turn cursor + c / i actions.
    ///
    /// Ctrl+Z chords (`o` / `O` / `h`) are handled locally
    /// because the modal-key dispatcher intercepts keys
    /// before the global bund-prefix resolver runs — without
    /// this branch a user pressing `Ctrl+Z o` inside the
    /// pane sees no effect.
    fn shell_pane_handle_key(&mut self, key: KeyEvent) {
        // 1.2.8+ — Help overlay swallows EVERY key.  Any
        // press dismisses the overlay; the underlying pane
        // state is preserved unchanged.
        if matches!(self.modal, Modal::ShellPane { show_help: true, .. }) {
            if let Modal::ShellPane { show_help, .. } = &mut self.modal {
                *show_help = false;
            }
            self.status = "shell help: closed".into();
            return;
        }

        // 1.2.8+ — Ctrl+B chord prefix (pane-local).  Kept
        // separate from the global `bund_pending` so we
        // don't fire global chords (Q quit, L load, …) from
        // inside the pane and so the suffix matching here
        // doesn't have to disambiguate by prefix.  Only
        // chord we currently honour is `Ctrl+B H` → help.
        if key.modifiers.contains(KeyModifiers::CONTROL)
            && matches!(key.code, KeyCode::Char('b') | KeyCode::Char('B'))
        {
            self.shell_ctrlb_pending = true;
            self.status = "Ctrl+B … (H help)".into();
            return;
        }
        if self.shell_ctrlb_pending {
            self.shell_ctrlb_pending = false;
            if matches!(
                key.code,
                KeyCode::Char('h') | KeyCode::Char('H')
            ) {
                if let Modal::ShellPane { show_help, .. } = &mut self.modal {
                    *show_help = true;
                }
                self.status = "shell help: any key to close".into();
                return;
            }
            // Any other suffix: clear pending state and fall
            // through to normal handling.
        }

        // Two-key Ctrl+Z chord, handled in-place.
        if key.modifiers.contains(KeyModifiers::CONTROL)
            && matches!(key.code, KeyCode::Char('z') | KeyCode::Char('Z'))
        {
            self.bund_pending = true;
            self.status =
                "Ctrl+Z … (o close · O reset · h selection)".into();
            return;
        }
        if self.bund_pending {
            self.bund_pending = false;
            // Translate the suffix into the appropriate
            // action.  Anything else clears the pending
            // state and falls through to normal handling so
            // a stray Ctrl+Z followed by a real keystroke
            // doesn't get stuck.
            match (key.code, key.modifiers.contains(KeyModifiers::SHIFT)) {
                (KeyCode::Char('o'), false) | (KeyCode::Char('O'), false) => {
                    self.open_shell_pane(false);
                    return;
                }
                (KeyCode::Char('O'), true) | (KeyCode::Char('o'), true) => {
                    self.open_shell_pane(true);
                    return;
                }
                (KeyCode::Char('h'), _) | (KeyCode::Char('H'), _) => {
                    self.toggle_shell_selection_mode();
                    return;
                }
                _ => {
                    // Fall through to normal handling.  No
                    // status update — the user sees the
                    // suffix appear / not-appear in the input
                    // and figures it out.
                }
            }
        }
        // PgUp/PgDown/Home/End scroll the turn-buffer in BOTH
        // selection and normal modes — they don't conflict
        // with command-history recall (↑↓) or selection-mode
        // turn navigation (↑↓).  Home/End are special:
        //   - In selection mode they ALSO move the turn cursor
        //     to the first / last turn (existing behaviour).
        //   - In normal mode they only affect scroll.
        // Step = 10 logical lines per Page key — render clamps
        // if the user scrolls past the top of the buffer.
        const SCROLL_PAGE: usize = 10;
        if matches!(
            key.code,
            KeyCode::PageUp | KeyCode::PageDown
        ) {
            if let Modal::ShellPane { scroll, .. } = &mut self.modal {
                match key.code {
                    KeyCode::PageUp => *scroll = scroll.saturating_add(SCROLL_PAGE),
                    KeyCode::PageDown => *scroll = scroll.saturating_sub(SCROLL_PAGE),
                    _ => unreachable!(),
                }
            }
            return;
        }

        // Selection mode first — narrower keyspace.
        let in_selection = matches!(
            self.modal,
            Modal::ShellPane { selection_mode: true, .. }
        );
        if in_selection {
            let history_len = self.shell_history.len();
            if let Modal::ShellPane {
                selection_cursor,
                scroll,
                ..
            } = &mut self.modal
            {
                match key.code {
                    KeyCode::Up => {
                        if *selection_cursor > 0 {
                            *selection_cursor -= 1;
                        }
                    }
                    KeyCode::Down => {
                        if *selection_cursor + 1 < history_len {
                            *selection_cursor += 1;
                        }
                    }
                    KeyCode::Home => {
                        *selection_cursor = 0;
                        // Match: walking to the first turn
                        // exposes the head of the buffer.
                        *scroll = usize::MAX / 2;
                    }
                    KeyCode::End => {
                        *selection_cursor = history_len.saturating_sub(1);
                        *scroll = 0;
                    }
                    KeyCode::Char('c') | KeyCode::Char('C') => {
                        self.shell_selection_copy();
                    }
                    KeyCode::Char('i') | KeyCode::Char('I') => {
                        self.shell_selection_insert();
                    }
                    KeyCode::Esc => {
                        // Esc inside selection mode just exits
                        // selection — second Esc closes the pane
                        // (matches the F6 filter ergonomics).
                        if let Modal::ShellPane { selection_mode, .. } =
                            &mut self.modal
                        {
                            *selection_mode = false;
                        }
                        self.status = "shell: back to normal mode".into();
                    }
                    _ => {}
                }
            }
            return;
        }

        // Normal shell mode.
        match key.code {
            KeyCode::Enter => {
                let line = match &self.modal {
                    Modal::ShellPane { input, .. } => {
                        input.as_str().to_string()
                    }
                    _ => return,
                };
                let trimmed = line.trim().to_string();
                if trimmed.is_empty() {
                    return;
                }
                // Block `exit` / `quit` from reaching nu.  Nu's
                // built-in `exit` calls `std::process::exit()`
                // unconditionally — if it ran, the entire
                // inkhaven process would die, taking unsaved
                // editor buffers with it.  Users typing `exit`
                // intend to close the SHELL pane (terminal
                // muscle memory), so we intercept and do
                // exactly that.  Also handles `exit 1`,
                // `quit 0`, etc. — any line whose first token
                // is `exit` or `quit`.  Engine + history are
                // preserved; reopen with `Ctrl+Z o`.
                let first_tok = trimmed
                    .split_whitespace()
                    .next()
                    .unwrap_or("");
                if matches!(first_tok, "exit" | "quit") {
                    if let Modal::ShellPane { input, .. } = &mut self.modal {
                        input.clear();
                    }
                    self.modal = Modal::None;
                    self.status =
                        "shell: closed via `exit` (state preserved · Ctrl+Z o to reopen)".into();
                    return;
                }
                // Reset input + history cursor; eval; push turn.
                // Also reset scroll so the new turn lands
                // visible at the bottom — anything else would
                // be confusing (user runs `ls`, expects to
                // see its output, but stays scrolled to old
                // content).
                if let Modal::ShellPane {
                    input,
                    command_history_cursor,
                    scroll,
                    ..
                } = &mut self.modal
                {
                    input.clear();
                    *command_history_cursor = None;
                    *scroll = 0;
                }
                // Command-history ring: dedup vs immediate
                // predecessor.  Push to both the in-memory
                // ring (Up-arrow recall) AND the per-project
                // SQLite (survives restart).
                if self.shell_command_history.last().map(String::as_str)
                    != Some(trimmed.as_str())
                {
                    self.shell_command_history.push(trimmed.clone());
                    if let Some(db) = self.shell_history_db.as_ref() {
                        db.push(&trimmed);
                    }
                }
                // Eval with the watchdog (1.2.8+).  Moves the
                // engine into a worker thread, polls main
                // thread with a wall-clock budget, recovers a
                // fresh engine on hard timeout.
                let out = self.shell_eval_with_watchdog(trimmed.clone());
                // Per-turn output truncation: a single `cat`
                // / `git log` can drop tens of thousands of
                // lines into stdout, which bloats memory and
                // makes ratatui rendering crawl.  Cap at
                // configured max_output_lines, replacing the
                // tail with a "(N more lines truncated)"
                // marker so the user knows.
                let max_lines = self.cfg.shell.max_output_lines.max(1);
                let stdout = truncate_to_lines(out.stdout, max_lines);
                let stderr = truncate_to_lines(out.stderr, max_lines);
                let turn = super::shell::ShellTurn {
                    command: trimmed,
                    stdout,
                    stderr,
                    success: out.success,
                };
                self.shell_history.push(turn);
                let cap = self.cfg.shell.max_buffered_turns.max(1);
                while self.shell_history.len() > cap {
                    self.shell_history.remove(0);
                }
            }
            KeyCode::Up => {
                if !self.shell_command_history.is_empty() {
                    let next_cursor = match self.modal {
                        Modal::ShellPane {
                            command_history_cursor: Some(0),
                            ..
                        } => 0,
                        Modal::ShellPane {
                            command_history_cursor: Some(i),
                            ..
                        } => i - 1,
                        Modal::ShellPane {
                            command_history_cursor: None,
                            ..
                        } => self.shell_command_history.len() - 1,
                        _ => return,
                    };
                    let entry =
                        self.shell_command_history[next_cursor].clone();
                    if let Modal::ShellPane {
                        input,
                        command_history_cursor,
                        ..
                    } = &mut self.modal
                    {
                        *command_history_cursor = Some(next_cursor);
                        input.clear();
                        for c in entry.chars() {
                            input.insert_char(c);
                        }
                    }
                }
            }
            KeyCode::Down => {
                if let Modal::ShellPane {
                    command_history_cursor: Some(cur),
                    ..
                } = self.modal
                {
                    let next = cur + 1;
                    if next >= self.shell_command_history.len() {
                        if let Modal::ShellPane {
                            input,
                            command_history_cursor,
                            ..
                        } = &mut self.modal
                        {
                            *command_history_cursor = None;
                            input.clear();
                        }
                    } else {
                        let entry =
                            self.shell_command_history[next].clone();
                        if let Modal::ShellPane {
                            input,
                            command_history_cursor,
                            ..
                        } = &mut self.modal
                        {
                            *command_history_cursor = Some(next);
                            input.clear();
                            for c in entry.chars() {
                                input.insert_char(c);
                            }
                        }
                    }
                }
            }
            KeyCode::Tab => {
                // 1.2.8+ — autocomplete commands, externals,
                // and filesystem paths.  Replace the token
                // under the cursor with either the single
                // match or the longest common prefix of all
                // matches.  Multiple matches surface as a
                // status-line list so the user can see what
                // else exists.
                self.shell_tab_complete();
            }
            // 1.2.8+ — readline-style line editing.  Single-
            // line prompt so we omit Ctrl+A=select-all,
            // Ctrl+U=undo, Ctrl+Y=redo from the editor's
            // textarea bindings (no selection model, no
            // undo history on the input ring).  Instead
            // bind the chords the way bash / zsh / readline
            // users expect:
            //   Ctrl+A   → start of line
            //   Ctrl+E   → end of line
            //   Ctrl+U   → kill from cursor to start
            //   Ctrl+K   → kill from cursor to end
            //   Ctrl+W   → kill the word before the cursor
            //   Ctrl+L   → clear scrollback
            //   Ctrl+D   → clear input if non-empty, close
            //              pane if empty (terminal idiom)
            //   Alt+B / Alt+F / Ctrl+Left / Ctrl+Right
            //            → word back / word forward
            //   Alt+Backspace → kill word back
            KeyCode::Char('a') | KeyCode::Char('A')
                if key.modifiers.contains(KeyModifiers::CONTROL) =>
            {
                if let Modal::ShellPane { input, .. } = &mut self.modal {
                    input.move_home();
                }
            }
            KeyCode::Char('e') | KeyCode::Char('E')
                if key.modifiers.contains(KeyModifiers::CONTROL) =>
            {
                if let Modal::ShellPane { input, .. } = &mut self.modal {
                    input.move_end();
                }
            }
            KeyCode::Char('u') | KeyCode::Char('U')
                if key.modifiers.contains(KeyModifiers::CONTROL) =>
            {
                if let Modal::ShellPane { input, .. } = &mut self.modal {
                    input.kill_to_start();
                }
            }
            KeyCode::Char('k') | KeyCode::Char('K')
                if key.modifiers.contains(KeyModifiers::CONTROL) =>
            {
                if let Modal::ShellPane { input, .. } = &mut self.modal {
                    input.kill_to_end();
                }
            }
            KeyCode::Char('w') | KeyCode::Char('W')
                if key.modifiers.contains(KeyModifiers::CONTROL) =>
            {
                if let Modal::ShellPane { input, .. } = &mut self.modal {
                    input.kill_word_left();
                }
            }
            KeyCode::Char('l') | KeyCode::Char('L')
                if key.modifiers.contains(KeyModifiers::CONTROL) =>
            {
                // Clear scrollback (terminal idiom).  Engine
                // state and command history are preserved —
                // this is purely a visual reset of the turn
                // buffer.
                self.shell_history.clear();
                if let Modal::ShellPane { scroll, .. } = &mut self.modal {
                    *scroll = 0;
                }
                self.status = "shell: scrollback cleared".into();
            }
            KeyCode::Char('d') | KeyCode::Char('D')
                if key.modifiers.contains(KeyModifiers::CONTROL) =>
            {
                let input_empty = matches!(
                    &self.modal,
                    Modal::ShellPane { input, .. } if input.is_empty()
                );
                if input_empty {
                    self.modal = Modal::None;
                    self.status =
                        "shell: closed via Ctrl+D (state preserved · Ctrl+Z o to reopen)".into();
                } else if let Modal::ShellPane { input, .. } = &mut self.modal {
                    input.clear();
                }
            }
            KeyCode::Char('b') | KeyCode::Char('B')
                if key.modifiers.contains(KeyModifiers::ALT) =>
            {
                if let Modal::ShellPane { input, .. } = &mut self.modal {
                    input.move_word_left();
                }
            }
            KeyCode::Char('f') | KeyCode::Char('F')
                if key.modifiers.contains(KeyModifiers::ALT) =>
            {
                if let Modal::ShellPane { input, .. } = &mut self.modal {
                    input.move_word_right();
                }
            }
            KeyCode::Left
                if key.modifiers.contains(KeyModifiers::CONTROL)
                    || key.modifiers.contains(KeyModifiers::ALT) =>
            {
                if let Modal::ShellPane { input, .. } = &mut self.modal {
                    input.move_word_left();
                }
            }
            KeyCode::Right
                if key.modifiers.contains(KeyModifiers::CONTROL)
                    || key.modifiers.contains(KeyModifiers::ALT) =>
            {
                if let Modal::ShellPane { input, .. } = &mut self.modal {
                    input.move_word_right();
                }
            }
            KeyCode::Backspace
                if key.modifiers.contains(KeyModifiers::ALT)
                    || key.modifiers.contains(KeyModifiers::CONTROL) =>
            {
                if let Modal::ShellPane { input, .. } = &mut self.modal {
                    input.kill_word_left();
                }
            }
            KeyCode::Esc => {
                // Esc closes the pane; engine + history
                // persist on App.
                self.modal = Modal::None;
                self.status =
                    "shell: closed (state preserved · Ctrl+Z o to reopen)".into();
            }
            // Home/End in normal mode go to the prompt's
            // beginning/end (text-input native behaviour),
            // BUT modified Home/End — Shift+Home / Shift+End —
            // jump scrollback to top/bottom.  Without the
            // modifier, the text-input handler picks them up
            // via the `_` arm below.
            KeyCode::Home if key.modifiers.contains(KeyModifiers::SHIFT) => {
                if let Modal::ShellPane { scroll, .. } = &mut self.modal {
                    *scroll = usize::MAX / 2;
                }
            }
            KeyCode::End if key.modifiers.contains(KeyModifiers::SHIFT) => {
                if let Modal::ShellPane { scroll, .. } = &mut self.modal {
                    *scroll = 0;
                }
            }
            _ => {
                if let Modal::ShellPane { input, .. } = &mut self.modal {
                    handle_text_input_key(input, key);
                }
            }
        }
    }

    /// 1.2.8+ — Ctrl+V Shift+U. Open the kill-ring picker.
    /// Empty ring → status message and no modal.
    fn open_kill_ring_picker(&mut self) {
        if self.kill_ring.is_empty() {
            self.status = "kill-ring: empty (nothing to restore)".into();
            return;
        }
        self.modal = Modal::KillRingPicker { cursor: 0 };
        self.status = format!(
            "kill-ring: {} entr{} · ↑↓ select · Enter restore · Esc cancel",
            self.kill_ring.len(),
            if self.kill_ring.len() == 1 { "y" } else { "ies" }
        );
    }

    /// 1.2.8+ — handle keystrokes inside `Modal::KillRingPicker`.
    fn kill_ring_picker_handle_key(&mut self, key: KeyEvent) {
        let Modal::KillRingPicker { cursor } = &mut self.modal else { return; };
        let len = self.kill_ring.len();
        if len == 0 {
            self.modal = Modal::None;
            return;
        }
        match key.code {
            KeyCode::Up => {
                if *cursor > 0 {
                    *cursor -= 1;
                }
            }
            KeyCode::Down => {
                if *cursor + 1 < len {
                    *cursor += 1;
                }
            }
            KeyCode::Home => *cursor = 0,
            KeyCode::End => *cursor = len.saturating_sub(1),
            KeyCode::Enter => {
                let idx = *cursor;
                self.modal = Modal::None;
                self.restore_kill_ring_entry(idx);
            }
            KeyCode::Esc => {
                self.modal = Modal::None;
                self.status = "kill-ring: cancelled".into();
            }
            _ => {}
        }
    }

    /// 1.2.8+ — restore the kill-ring entry at `idx`. Removes
    /// it from the ring (whether or not the restore succeeds —
    /// failure cases reinsert on the error path, mirroring
    /// `undo_last_delete`'s semantics).
    fn restore_kill_ring_entry(&mut self, idx: usize) {
        if idx >= self.kill_ring.len() {
            self.status = "kill-ring: index out of range".into();
            return;
        }
        let stash = self.kill_ring.remove(idx).expect("idx checked");
        let parent = stash
            .parent_id
            .and_then(|id| self.hierarchy.get(id).cloned());
        let position = match stash.anchor_id {
            Some(id) if self.hierarchy.get(id).is_some() => {
                crate::store::InsertPosition::After(id)
            }
            _ => crate::store::InsertPosition::End,
        };
        let created = match self.store.create_node(
            &self.cfg,
            &self.hierarchy,
            NodeKind::Paragraph,
            &stash.title,
            parent.as_ref(),
            Some(&stash.slug),
            position,
        ) {
            Ok(n) => n,
            Err(e) => {
                // Reinsert at the same index so the user can
                // retry; status names the failure.
                self.kill_ring.insert(idx, stash);
                self.status = format!("kill-ring restore: create_node failed: {e}");
                return;
            }
        };
        if let Some(rel) = created.file.as_ref() {
            let abs = self.layout.root.join(rel);
            if let Err(e) = std::fs::write(&abs, &stash.content) {
                self.status = format!(
                    "kill-ring restore: paragraph created at `{}` but body write failed: {e}",
                    created.slug
                );
                self.reload_hierarchy();
                return;
            }
        }
        let mut updated = created.clone();
        updated.tags = stash.tags;
        updated.linked_paragraphs = stash.linked_paragraphs;
        updated.status = stash.status;
        updated.target_words = stash.target_words;
        if stash.content_type.is_some() {
            updated.content_type = stash.content_type;
        }
        updated.event = stash.event;
        updated.modified_at = chrono::Utc::now();
        crate::store::reconcile_event_orphan_tag(&mut updated);
        if let Err(e) = self
            .store
            .raw()
            .update_metadata(updated.id, updated.to_json())
        {
            tracing::warn!(
                target: "inkhaven::undo",
                "kill-ring metadata restore failed for {}: {e}",
                updated.id
            );
        }
        self.reload_hierarchy();
        self.status = format!(
            "↺ restored `{}` from kill-ring (new uuid {} — cross-refs to old uuid stay broken)",
            updated.title,
            updated.id.simple()
        );
    }

    /// 1.2.7+ — Alt+Left. Browser-style back through the
    /// visited-paragraph history.
    fn navigate_visited_back(&mut self) {
        if self.visited_history.is_empty() || self.visited_cursor == 0 {
            self.status = "navigate: already at the start of the visit history".into();
            return;
        }
        self.visited_cursor -= 1;
        let id = self.visited_history[self.visited_cursor];
        self.visited_skip_next_push = true;
        match self.open_paragraph_by_uuid(id) {
            Ok(()) => {
                let title = self
                    .hierarchy
                    .get(id)
                    .map(|n| n.title.clone())
                    .unwrap_or_else(|| id.to_string());
                self.status = format!(
                    "← back · `{title}` ({}/{})",
                    self.visited_cursor + 1,
                    self.visited_history.len()
                );
            }
            Err(e) => {
                // Failed to open — restore cursor; the
                // skip-flag will be consumed only if we
                // actually loaded.
                self.visited_skip_next_push = false;
                self.visited_cursor += 1;
                self.status = format!("navigate: couldn't open back-target: {e}");
            }
        }
    }

    /// 1.2.7+ — Alt+Right. Forward through the visited-
    /// paragraph history. No-op if already at the head.
    fn navigate_visited_forward(&mut self) {
        if self.visited_cursor + 1 >= self.visited_history.len() {
            self.status = "navigate: already at the end of the visit history".into();
            return;
        }
        self.visited_cursor += 1;
        let id = self.visited_history[self.visited_cursor];
        self.visited_skip_next_push = true;
        match self.open_paragraph_by_uuid(id) {
            Ok(()) => {
                let title = self
                    .hierarchy
                    .get(id)
                    .map(|n| n.title.clone())
                    .unwrap_or_else(|| id.to_string());
                self.status = format!(
                    "→ forward · `{title}` ({}/{})",
                    self.visited_cursor + 1,
                    self.visited_history.len()
                );
            }
            Err(e) => {
                self.visited_skip_next_push = false;
                self.visited_cursor -= 1;
                self.status = format!("navigate: couldn't open forward-target: {e}");
            }
        }
    }

    /// 1.2.7+ — Ctrl+Shift+M. Flip TUI mouse capture so the
    /// user can use terminal-native drag-to-select +
    /// system-clipboard copy in the editor and AI panes.
    /// Status reports the new state. No HJSON persistence —
    /// the choice is per-session; default is always ON.
    fn toggle_mouse_capture(&mut self) {
        use crossterm::execute;
        self.mouse_captured = !self.mouse_captured;
        let mut stdout = std::io::stdout();
        let result = if self.mouse_captured {
            execute!(stdout, EnableMouseCapture)
        } else {
            execute!(stdout, DisableMouseCapture)
        };
        match result {
            Ok(()) => {
                self.status = if self.mouse_captured {
                    "mouse capture: ON — click-to-focus + scroll wheel; native selection disabled".into()
                } else {
                    "mouse capture: OFF — terminal-native selection enabled (drag-select + Cmd+C / Ctrl+Shift+C). Ctrl+Shift+M to re-enable.".into()
                };
            }
            Err(e) => {
                // Flip back; report the error.
                self.mouse_captured = !self.mouse_captured;
                self.status = format!("mouse capture toggle failed: {e}");
            }
        }
    }

    /// Ctrl+B E — flip `sound.enabled` in the live config + on disk,
    /// and toggle the live SoundPlayer's enabled flag. No-ops on hosts
    /// without an audio device beyond updating the config (so the
    /// preference persists for a future launch on a host that does).
    fn toggle_sound(&mut self) {
        let new_value = !self.cfg.sound.enabled;
        let config_path = self.layout.config_path();
        let raw = match std::fs::read_to_string(&config_path) {
            Ok(s) => s,
            Err(e) => {
                self.status =
                    format!("sound toggle aborted: read {}: {e}", config_path.display());
                return;
            }
        };
        let updated = match set_sound_enabled_in_hjson(&raw, new_value) {
            Ok(s) => s,
            Err(reason) => {
                self.status = format!(
                    "sound toggle aborted: can't rewrite {}: {reason}",
                    config_path.display()
                );
                return;
            }
        };
        if let Err(e) = std::fs::write(&config_path, &updated) {
            self.status =
                format!("sound toggle aborted: write {}: {e}", config_path.display());
            return;
        }

        self.cfg.sound.enabled = new_value;
        if let Some(sp) = self.sound.as_mut() {
            sp.enabled = new_value;
        }
        let label = if new_value { "ON" } else { "OFF" };
        let audio_note = if self.sound.is_some() {
            ""
        } else {
            " (no audio device — silent, but preference saved)"
        };
        self.status =
            format!("Typewriter sound {label}{audio_note} · saved to {}", config_path.display());
    }

    /// Try the in-`#image(…)` picker; returns false when the cursor
    /// isn't inside an image call (so the caller can fall through to
    /// Places RAG). Returns true even when the picker is empty —
    /// "you're inside `#image()` but this paragraph has no sibling
    /// images" is still better feedback than silently jumping to
    /// Places RAG.
    fn try_open_image_picker(&mut self) -> bool {
        let Some(doc) = self.opened.as_ref() else {
            return false;
        };
        let (row, col) = doc.textarea.cursor();
        let line = doc.textarea.lines().get(row).cloned().unwrap_or_default();
        let ctx = match detect_image_call_context(&line, col) {
            Some(c) => c,
            None => return false,
        };
        // Find sibling Image nodes in the paragraph's parent.
        let entries = self.sibling_image_entries(doc.id);
        if entries.is_empty() {
            self.status = "no sibling images at this level — import one with F3 first".into();
            self.modal = Modal::ImagePicker {
                entries,
                cursor: 0,
                close_quote: !ctx.closing_quote_present,
            };
            return true;
        }
        self.status =
            "↑↓ select · Enter insert · Esc cancel".into();
        self.modal = Modal::ImagePicker {
            entries,
            cursor: 0,
            close_quote: !ctx.closing_quote_present,
        };
        true
    }

    /// Sibling Image nodes of the paragraph identified by `para_id`,
    /// sorted by their `order` field. Each entry carries the filename
    /// (already in `NN-slug.<ext>` form), the display title, and the
    /// file size for the picker readout.
    fn sibling_image_entries(&self, para_id: Uuid) -> Vec<ImagePickerEntry> {
        let Some(para) = self.hierarchy.get(para_id) else {
            return Vec::new();
        };
        let mut out: Vec<ImagePickerEntry> = self
            .hierarchy
            .children_of(para.parent_id)
            .into_iter()
            .filter(|n| n.kind == NodeKind::Image)
            .map(|n| {
                let size_bytes = n
                    .file
                    .as_ref()
                    .map(|rel| {
                        std::fs::metadata(self.layout.root.join(rel))
                            .map(|m| m.len())
                            .unwrap_or(0)
                    })
                    .unwrap_or(0);
                ImagePickerEntry {
                    fname: n.fs_name(),
                    title: n.title.clone(),
                    size_bytes,
                }
            })
            .collect();
        out.sort_by(|a, b| a.fname.cmp(&b.fname));
        out
    }

    fn image_picker_handle_key(&mut self, key: KeyEvent) -> bool {
        let Modal::ImagePicker {
            entries, cursor, ..
        } = &mut self.modal
        else {
            return false;
        };
        let total = entries.len();
        match key.code {
            KeyCode::Up => {
                if *cursor > 0 {
                    *cursor -= 1;
                }
                true
            }
            KeyCode::Down => {
                if *cursor + 1 < total {
                    *cursor += 1;
                }
                true
            }
            KeyCode::Home => {
                *cursor = 0;
                true
            }
            KeyCode::End => {
                *cursor = total.saturating_sub(1);
                true
            }
            KeyCode::Enter => {
                self.commit_image_picker();
                true
            }
            _ => false,
        }
    }

    fn commit_image_picker(&mut self) {
        let (fname, close_quote) = match &self.modal {
            Modal::ImagePicker {
                entries,
                cursor,
                close_quote,
            } => match entries.get(*cursor) {
                Some(e) => (e.fname.clone(), *close_quote),
                None => {
                    self.modal = Modal::None;
                    return;
                }
            },
            _ => return,
        };
        // Insert filename + optional `"` at the cursor in the editor.
        let insert = if close_quote {
            format!("{fname}\"")
        } else {
            fname.clone()
        };
        if let Some(doc) = self.opened.as_mut() {
            doc.textarea.insert_str(&insert);
            doc.dirty = true;
        }
        self.modal = Modal::None;
        self.status = format!("inserted `{fname}` into #image(…)");
    }

    fn open_function_picker(&mut self) {
        if self.opened.is_none() {
            self.status = "function picker needs an open paragraph".into();
            return;
        }
        self.modal = Modal::FunctionPicker {
            filter: TextInput::new(),
            cursor: 0,
        };
        self.status = "Type to filter · ↑↓ select · Enter insert · Esc cancel".into();
    }

    fn function_picker_handle_key(&mut self, key: KeyEvent) -> bool {
        // Recompute the filtered list each keystroke so the cursor
        // stays attached to a visible row.
        let (filter_text, mut cursor_value) = match &self.modal {
            Modal::FunctionPicker { filter, cursor } => (filter.as_str().to_string(), *cursor),
            _ => return false,
        };
        let matches = filter_functions(&filter_text);
        let total = matches.len();
        let mut closed = false;
        let mut commit_now = false;
        let mut filter_dirty = false;
        match key.code {
            KeyCode::Up => {
                if cursor_value > 0 {
                    cursor_value -= 1;
                }
            }
            KeyCode::Down => {
                if cursor_value + 1 < total {
                    cursor_value += 1;
                }
            }
            KeyCode::Home => cursor_value = 0,
            KeyCode::End => cursor_value = total.saturating_sub(1),
            KeyCode::PageUp => cursor_value = cursor_value.saturating_sub(10),
            KeyCode::PageDown => {
                cursor_value = (cursor_value + 10).min(total.saturating_sub(1));
            }
            KeyCode::Enter => commit_now = true,
            KeyCode::Esc => closed = true,
            _ => {
                if let Modal::FunctionPicker { filter, cursor } = &mut self.modal {
                    handle_text_input_key(filter, key);
                    *cursor = 0;
                    filter_dirty = true;
                    let _ = cursor;
                }
            }
        }
        if filter_dirty {
            return true;
        }
        if closed {
            self.modal = Modal::None;
            return true;
        }
        if commit_now {
            self.commit_function_picker(&matches, cursor_value);
            return true;
        }
        if let Modal::FunctionPicker { cursor, .. } = &mut self.modal {
            *cursor = cursor_value;
        }
        true
    }

    fn commit_function_picker(&mut self, matches: &[super::typst_funcs::TypstFn], cursor_value: usize) {
        let Some(picked) = matches.get(cursor_value).copied() else {
            self.modal = Modal::None;
            return;
        };
        // Detect the syntactic mode at the cursor via tree-sitter-
        // typst so we only emit the `#` prefix when the cursor is in
        // markup. Inside `{ code }`, function-call arguments, `let`
        // RHS, or math, the bare identifier is what typst expects.
        let mode = self
            .opened
            .as_ref()
            .map(|doc| {
                let source = doc.textarea.lines().join("\n");
                let (row, col) = doc.textarea.cursor();
                let byte = byte_offset_for_cursor(&source, row, col);
                super::highlight::typst_mode_at(&source, byte)
            })
            .unwrap_or(super::highlight::TypstMode::Markup);
        let prefix = mode.call_prefix();
        let opener = format!("{prefix}{}(", picked.name);
        if let Some(doc) = self.opened.as_mut() {
            doc.textarea.insert_str(&opener);
            doc.textarea.insert_str(")");
            doc.textarea.move_cursor(CursorMove::Back);
            doc.dirty = true;
        }
        self.modal = Modal::None;
        let mode_tag = match mode {
            super::highlight::TypstMode::Markup => "markup",
            super::highlight::TypstMode::Code => "code",
            super::highlight::TypstMode::Math => "math",
        };
        self.status = format!("inserted {prefix}{}( … ) · {mode_tag} mode", picked.name);
    }

    fn cycle_ai_mode(&mut self) {
        self.ai_mode = self.ai_mode.next();
        match self.ai_mode {
            AiMode::None => {
                self.status = "AI scope: None (only the prompt is sent)".into()
            }
            // 1.2.21+ — Facts is a sticky *session* scope, not a per-query
            // attach: entering it seeds the chat with the world's facts +
            // a fact-analysis framing (which sets its own status).
            AiMode::Facts => self.seed_facts_session(),
            other => {
                self.status = format!(
                    "AI scope: {} (will prepend matching context to next prompt)",
                    other.label()
                )
            }
        }
    }

    fn toggle_inference_mode(&mut self) {
        self.inference_mode = self.inference_mode.toggle();
        self.status = match self.inference_mode {
            InferenceMode::Local => {
                "Inference: Local (model uses only supplied context)".into()
            }
            InferenceMode::Full => {
                "Inference: Full (model uses context + its own knowledge)".into()
            }
        };
    }

    /// Assemble the prefix the active AI scope should prepend to the
    /// user's prompt. Returns `Ok(Some(text))` when the scope produced
    /// content, `Ok(None)` for `AiMode::None`, or `Err(reason)` when the
    /// scope was requested but produced nothing (no selection, no open
    /// paragraph, no enclosing branch). The status message in `Err` is
    /// surfaced to the user verbatim.
    fn build_ai_mode_context(&self) -> Result<Option<String>, String> {
        match self.ai_mode {
            AiMode::None => Ok(None),
            AiMode::Selection => {
                let Some(doc) = self.opened.as_ref() else {
                    return Err("AI scope `Selection` needs an open paragraph".into());
                };
                let Some(((r1, c1), (r2, c2))) = doc.textarea.selection_range() else {
                    return Err("AI scope `Selection` needs a non-empty selection in the editor".into());
                };
                let text = slice_lines(doc.textarea.lines(), r1, c1, r2, c2);
                if text.trim().is_empty() {
                    return Err("AI scope `Selection` selection was empty".into());
                }
                Ok(Some(format!(
                    "── Editor selection ──\n{text}\n── end selection ──"
                )))
            }
            AiMode::Paragraph => {
                let Some(doc) = self.opened.as_ref() else {
                    return Err("AI scope `Paragraph` needs an open paragraph".into());
                };
                let live = doc.textarea.lines().join("\n");
                let mut out = String::new();
                if let Some(split) = doc.split.as_ref() {
                    // In split-edit mode the user sees the snapshot AND
                    // the live buffer side by side; include both so the
                    // model can compare.
                    out.push_str("── Paragraph snapshot (split-edit copy) ──\n");
                    out.push_str(&split.snapshot_lines.join("\n"));
                    out.push_str("\n── end snapshot ──\n\n");
                }
                out.push_str("── Paragraph: ");
                out.push_str(&doc.title);
                out.push_str(" ──\n");
                out.push_str(&live);
                out.push_str("\n── end paragraph ──");
                // 1.2.4+: also include every linked paragraph's
                // body so the model has the related-material the
                // user explicitly curated. Direct outgoing only
                // (matches the status-bar "links N" count). Bodies
                // come from disk (saved state) — same source the
                // export / similar-paragraph paths use.
                let linked_ids: Vec<Uuid> = self
                    .hierarchy
                    .get(doc.id)
                    .map(|n| n.linked_paragraphs.clone())
                    .unwrap_or_default();
                for id in linked_ids {
                    let Some(linked) = self.hierarchy.get(id) else { continue };
                    let Some(rel) = linked.file.as_ref() else { continue };
                    let abs = self.layout.root.join(rel);
                    let body = std::fs::read_to_string(&abs).unwrap_or_default();
                    out.push_str("\n\n── Linked paragraph: ");
                    out.push_str(&linked.title);
                    out.push_str(" ──\n");
                    out.push_str(&body);
                    out.push_str("\n── end linked paragraph ──");
                }
                Ok(Some(out))
            }
            AiMode::Subchapter | AiMode::Chapter | AiMode::Book => {
                let scope_kind = match self.ai_mode {
                    AiMode::Subchapter => NodeKind::Subchapter,
                    AiMode::Chapter => NodeKind::Chapter,
                    AiMode::Book => NodeKind::Book,
                    _ => unreachable!(),
                };
                let mode_label = self.ai_mode.label();
                // Anchor on the open paragraph if any, otherwise the tree
                // cursor — gives the user a sensible default whether they
                // were editing or browsing when they cycled to this scope.
                let anchor_id = self
                    .opened
                    .as_ref()
                    .map(|d| d.id)
                    .or_else(|| self.rows.get(self.tree_cursor).map(|(id, _)| *id))
                    .ok_or_else(|| {
                        format!("AI scope `{mode_label}` needs an open paragraph or tree cursor")
                    })?;
                let anchor = self
                    .hierarchy
                    .get(anchor_id)
                    .ok_or_else(|| format!("AI scope `{mode_label}` anchor vanished"))?;
                // Walk up to the enclosing branch of `scope_kind`. The
                // anchor itself counts if it is already that kind.
                let scope_node = if anchor.kind == scope_kind {
                    Some(anchor.clone())
                } else {
                    self.hierarchy
                        .ancestors(anchor)
                        .into_iter()
                        .find(|n| n.kind == scope_kind)
                        .cloned()
                };
                let Some(scope_node) = scope_node else {
                    return Err(format!(
                        "AI scope `{mode_label}` requires the cursor to be inside a {}",
                        scope_kind.as_str()
                    ));
                };
                let mut chunks: Vec<String> = Vec::new();
                for id in self.hierarchy.collect_subtree(scope_node.id) {
                    let Some(node) = self.hierarchy.get(id) else {
                        continue;
                    };
                    if node.kind != NodeKind::Paragraph {
                        continue;
                    }
                    if let Ok(Some(bytes)) = self.store.get_content(node.id) {
                        let body = String::from_utf8_lossy(&bytes).to_string();
                        chunks.push(format!(
                            "── {} ──\n{}",
                            self.title_breadcrumb(node.id),
                            body
                        ));
                    }
                }
                if chunks.is_empty() {
                    return Err(format!(
                        "AI scope `{mode_label}` `{}` has no paragraphs to send",
                        scope_node.title
                    ));
                }
                let header = format!(
                    "── {} context: {} ({} paragraph(s)) ──",
                    mode_label,
                    scope_node.title,
                    chunks.len()
                );
                Ok(Some(format!(
                    "{header}\n\n{}\n── end {} context ──",
                    chunks.join("\n\n"),
                    mode_label.to_lowercase()
                )))
            }
            // 1.2.21+ — the Facts scope delivers the Facts book as a
            // pre-seeded, *visible* chat-history prologue (see
            // `seed_facts_session`), not as a per-query prefix — so the
            // scope contributes nothing here.  It stays sticky across
            // turns (no auto-reset), so the seeded facts + the
            // fact-analysis system prompt frame the whole conversation.
            AiMode::Facts => Ok(None),
        }
    }

    /// 1.2.21+ — every non-empty paragraph of the Facts system book,
    /// each as a `── breadcrumb ──\nbody` chunk.  Empty when the project
    /// has no Facts book or it holds nothing.  Backs the Facts-scope
    /// chat seed and the `Ctrl+B Shift+X` fact-check grounding.
    fn gather_facts_chunks(&self) -> Vec<String> {
        let ids = self.facts_paragraph_ids();
        self.facts_chunks_for(&ids)
    }

    /// 1.2.21+ — every paragraph id under the Facts book, in subtree
    /// order.  Empty when there's no Facts book.  Backs the seed and
    /// FF.5's linked-fact prioritisation.
    fn facts_paragraph_ids(&self) -> Vec<Uuid> {
        let Some(facts_id) = self.system_book_id(crate::store::SYSTEM_TAG_FACTS) else {
            return Vec::new();
        };
        self.hierarchy
            .collect_subtree(facts_id)
            .into_iter()
            .filter(|id| {
                self.hierarchy
                    .get(*id)
                    .map(|n| n.kind == NodeKind::Paragraph)
                    .unwrap_or(false)
            })
            .collect()
    }

    /// 1.2.21+ FF.4d — Ctrl+B Shift+J — cycle through the last
    /// fact-check's findings, jumping the editor cursor to each flagged
    /// claim in the open paragraph.  Best-effort jump (the model's quoted
    /// claim can be paraphrased); the fact + explanation always shows on
    /// the status bar.
    fn next_fact_finding(&mut self) {
        let n = self.fact_check_nav.findings.len();
        if n == 0 {
            self.status =
                "no fact-check findings — run Ctrl+B Shift+X first".into();
            return;
        }
        let open_id = self.opened.as_ref().map(|d| d.id);
        if open_id.is_none() || open_id != self.fact_check_nav.target {
            self.status =
                "fact-check findings are for another paragraph — re-run Ctrl+B Shift+X here"
                    .into();
            return;
        }
        let new_idx = self.fact_check_nav.idx.wrapping_add(1) % n;
        self.fact_check_nav.idx = new_idx;
        let f = self.fact_check_nav.findings[new_idx].clone();
        let jumped = self.jump_cursor_to_text(&f.claim);
        if jumped {
            self.change_focus(Focus::Editor);
        }
        let loc = if jumped { "" } else { " (phrase not located)" };
        self.status = format!(
            "fact finding {}/{}{loc}: {} — {}",
            new_idx + 1,
            n,
            f.fact,
            f.detail,
        );
    }

    /// Move the editor cursor to the first case-insensitive occurrence
    /// of `needle` in the open paragraph.  Returns whether it landed.
    fn jump_cursor_to_text(&mut self, needle: &str) -> bool {
        let needle = needle.trim().to_lowercase();
        if needle.is_empty() {
            return false;
        }
        let Some(doc) = self.opened.as_mut() else {
            return false;
        };
        let pos = doc
            .textarea
            .lines()
            .iter()
            .enumerate()
            .find_map(|(row, line)| {
                // `byte_col` is an offset into the *lowercased* line; slice
                // that same string, not the original — `to_lowercase()` can
                // change byte length (Turkish İ, ẞ, ligatures), so reusing
                // the offset against `line` could split a char and panic.
                // The char column matches the original except for the rare
                // length-changing fold, which is fine for a "jump near" move.
                let lower = line.to_lowercase();
                lower
                    .find(&needle)
                    .map(|byte_col| (row, lower[..byte_col].chars().count()))
            });
        match pos {
            Some((row, col)) => {
                doc.textarea
                    .move_cursor(CursorMove::Jump(row as u16, col as u16));
                true
            }
            None => false,
        }
    }

    /// 1.2.21+ — build `── breadcrumb ──\nbody` chunks for the given
    /// node ids, keeping only non-empty paragraphs (empties + non-
    /// paragraphs are skipped).  Order follows `ids`.  Backs both the
    /// whole-book Facts seed and the FF.1 search-subset seed.
    fn facts_chunks_for(&self, ids: &[Uuid]) -> Vec<String> {
        let mut chunks = Vec::new();
        for &id in ids {
            let Some(node) = self.hierarchy.get(id) else {
                continue;
            };
            if node.kind != NodeKind::Paragraph {
                continue;
            }
            if let Ok(Some(bytes)) = self.store.get_content(id) {
                let body = String::from_utf8_lossy(&bytes).to_string();
                if body.trim().is_empty() {
                    continue;
                }
                chunks.push(format!(
                    "── {} ──\n{}",
                    self.title_breadcrumb(id),
                    body
                ));
            }
        }
        chunks
    }

    /// 1.2.21+ — entering the Facts scope seeds the chat with a visible
    /// prologue: the world's established facts as a user turn (so the
    /// model treats them as ground truth) plus a short assistant
    /// acknowledgement.  Idempotent — a history that already carries the
    /// seed (its user turn starts with [`FACTS_SEED_MARKER`]) isn't
    /// re-seeded, so cycling F9 in and out, or reloading a persisted
    /// chat, never duplicates it.  The fact-analysis system prompt is
    /// applied in the send path while the sticky scope stays `Facts`.
    fn seed_facts_session(&mut self) {
        let chunks = self.gather_facts_chunks();
        if chunks.is_empty() {
            self.status =
                "Facts scope — the Facts book is empty; collect some facts first".into();
            return;
        }
        // 1.2.21+ FF.4a — freshness: a prior seed whose facts payload
        // still matches the current Facts book is left as-is; if the
        // book changed (you edited / extracted facts since), re-cycling
        // F9 to Facts refreshes the seed rather than reporting "active".
        let fresh_payload = chunks.join("\n\n");
        let existing_payload = self.chat_history.iter().find_map(|t| match t {
            ChatTurn::User(s) if s.starts_with(FACTS_SEED_MARKER) => {
                s.split_once("\n\n").map(|(_, body)| body.to_string())
            }
            _ => None,
        });
        if existing_payload.as_deref() == Some(fresh_payload.as_str()) {
            let n = chunks.len();
            self.status = format!(
                "Facts session active ({n} fact{}, unchanged) — ask a fact-check question (F9 to exit)",
                if n == 1 { "" } else { "s" },
            );
            return;
        }
        let refreshed = existing_payload.is_some();
        let n = self.apply_facts_seed(chunks);
        let plural = if n == 1 { "y" } else { "ies" };
        self.status = if refreshed {
            format!(
                "Facts session: refreshed to {n} fact entr{plural} (the Facts book changed) — ask away"
            )
        } else {
            format!(
                "Facts session: loaded {n} fact entr{plural} — ask a fact-check question (F9 to exit)"
            )
        };
    }

    /// 1.2.21+ — (re)seed the chat with a Facts prologue built from
    /// `chunks`: a marked user turn carrying the facts (so the model
    /// treats them as ground truth) + a short assistant ack, prepended
    /// ahead of any existing conversation, and switch to the sticky
    /// Facts scope.  Any prior facts seed (its `⟦Facts⟧` user turn + the
    /// ack after it) is removed first, so a re-seed *swaps* the grounding
    /// without duplicating or discarding the rest of the chat.  Shared by
    /// the F9 whole-book seed and the FF.1 search-subset seed.  Returns
    /// the number of facts seeded.
    fn apply_facts_seed(&mut self, chunks: Vec<String>) -> usize {
        if let Some(i) = self.chat_history.iter().position(
            |t| matches!(t, ChatTurn::User(s) if s.starts_with(FACTS_SEED_MARKER)),
        ) {
            // Drop the ack right after the seed, then the seed itself.
            if self
                .chat_history
                .get(i + 1)
                .is_some_and(|t| matches!(t, ChatTurn::Assistant(_)))
            {
                self.chat_history.remove(i + 1);
            }
            self.chat_history.remove(i);
        }
        let n = chunks.len();
        let plural = if n == 1 { "y" } else { "ies" };
        let lang = crate::ai::prompts::iso_from_long(&self.cfg.language);
        let user_turn = format!(
            "{FACTS_SEED_MARKER}{} ({n} entr{plural})\n\n{}",
            facts_seed_intro(lang),
            chunks.join("\n\n"),
        );
        let ack = facts_seed_ack(lang).to_string();
        let mut seeded = vec![ChatTurn::User(user_turn), ChatTurn::Assistant(ack)];
        seeded.append(&mut self.chat_history);
        self.chat_history = seeded;
        self.ai_mode = AiMode::Facts;
        self.chat_history_scroll = 0;
        n
    }

    fn clear_chat_history(&mut self) {
        let turns = self.chat_history.len();
        self.chat_history.clear();
        self.pending_chat_user_msg = None;
        self.inference = None;
        // Reset chat-history scroll / search / selection — there's
        // nothing left to scroll or act on.
        self.chat_history_scroll = 0;
        self.chat_search = None;
        self.chat_selection = None;
        // Also dismiss any active grammar-correction overlay — the AI
        // result it derived from is being discarded, so keeping a
        // baseline tied to a forgotten correction is confusing.
        if let Some(doc) = self.opened.as_mut() {
            doc.correction_baseline = None;
        }
        self.status = if turns == 0 {
            "AI chat history already empty".into()
        } else {
            format!("AI chat cleared ({turns} turn(s) discarded)")
        };
    }

    fn open_help_query_modal(&mut self) {
        self.modal = Modal::HelpQuery {
            input: TextInput::new(),
        };
        self.status = "Help — type a question, Enter to ask, Esc to cancel".into();
    }

    /// Run a Help-book RAG inference for `query`. Builds a constrained
    /// prompt — the model is instructed to answer using ONLY the supplied
    /// Help excerpts and to admit when the context is insufficient — then
    /// streams the result into the AI pane. The AI pane is read-only by
    /// construction (no editor lives there), so the user can scroll the
    /// answer but can't edit it.
    /// Ctrl+B W toggles full-screen "typewriter mode". When on, every
    /// pane except the editor (and any floating modal) is hidden;
    /// focus is forced onto the editor so typing lands in the
    /// buffer. The same chord disables it.
    fn toggle_typewriter_mode(&mut self) {
        self.typewriter_mode = !self.typewriter_mode;
        if self.typewriter_mode {
            self.ai_fullscreen = false; // the two fullscreens are exclusive
            // Force focus to the editor so the user can start typing
            // immediately; the search bar / AI prompt are hidden
            // anyway, so leaving focus on them would be confusing.
            self.change_focus(Focus::Editor);
            self.status = "focus mode · Ctrl+B W to exit".into();
        } else {
            self.status = "focus mode off".into();
        }
    }

    /// Ctrl+B K toggles full-screen AI mode. Layout: top area split
    /// 50/50 — AI pane on the left, chat history on the right —
    /// over a full-width AI prompt at the bottom. Same chord
    /// returns to the four-pane layout.
    fn toggle_ai_fullscreen(&mut self) {
        self.ai_fullscreen = !self.ai_fullscreen;
        // Always start scrolled to the bottom (newest visible). The
        // user can PageUp to walk back through the history.
        self.chat_history_scroll = 0;
        // Wipe any in-flight chat search / selection; the layout
        // transition is an obvious break in user intent.
        self.chat_search = None;
        self.chat_selection = None;
        if self.ai_fullscreen {
            self.typewriter_mode = false; // exclusive with typewriter
            // Restore previously-saved chat history if the in-memory
            // list is currently empty. The user explicitly asked for
            // "if chat is empty, restore from previous state" — never
            // overwrite a live session.
            if self.chat_history.is_empty() {
                match self.load_chat_history_from_disk() {
                    Ok(turns) if turns > 0 => {
                        self.status = format!(
                            "AI fullscreen · restored {turns} turn(s) · Ctrl+B K to exit · Ctrl+F to search history"
                        );
                    }
                    Ok(_) => {
                        self.status =
                            "AI fullscreen · ↑↓/PgUp/PgDn scrolls history · Ctrl+F search · Ctrl+B K to exit".into();
                    }
                    Err(e) => {
                        tracing::warn!("chat history restore failed: {e}");
                        self.status =
                            "AI fullscreen · ↑↓/PgUp/PgDn scrolls history · Ctrl+F search · Ctrl+B K to exit".into();
                    }
                }
            } else {
                self.status =
                    "AI fullscreen · ↑↓/PgUp/PgDn scrolls history · Ctrl+F search · Ctrl+B K to exit".into();
            }
            // Drop focus onto the AI prompt so the user can start
            // typing the next message immediately — the AI pane has
            // no input role and the editor / tree / search bar are
            // hidden in this layout anyway.
            self.change_focus(Focus::AiPrompt);
        } else {
            // Persist the current chat to disk before leaving the
            // layout. Best-effort: write failures are logged but
            // don't block the toggle.
            match self.save_chat_history_to_disk() {
                Ok(()) => {
                    self.status = format!(
                        "AI fullscreen off · {} turn(s) saved",
                        self.chat_history.len()
                    );
                }
                Err(e) => {
                    tracing::warn!("chat history save failed: {e}");
                    self.status = "AI fullscreen off (chat history save failed — see logs)".into();
                }
            }
        }
    }

    /// Editor meta `Ctrl+B R`: advance the open paragraph's status one
    /// step through the workflow ring. The cycle wraps back to None
    /// after Ready; pressing R repeatedly walks the whole sequence
    /// without any other UI. Persisted to bdslib so it survives the
    /// next launch.
    /// Single-paragraph status cycle, used by the multi-select
    /// path in `cycle_paragraph_status`. No hook firing — that's
    /// reserved for the explicit one-paragraph chord so a bulk
    /// op doesn't fire 30 hooks in sequence.
    fn cycle_status_single(&mut self, id: Uuid) -> std::result::Result<(), String> {
        let node = self
            .hierarchy
            .get(id)
            .cloned()
            .ok_or_else(|| format!("paragraph {id} not in hierarchy"))?;
        if node.kind != NodeKind::Paragraph {
            return Err(format!("`{}` is not a paragraph", node.title));
        }
        let next = next_status(node.status.as_deref());
        let mut updated = node.clone();
        updated.status = if next == "None" {
            None
        } else {
            Some(next.to_string())
        };
        updated.target_hit_at_status = None;
        self.store
            .raw()
            .update_metadata(id, updated.to_json())
            .map_err(|e| format!("store update: {e}"))?;
        Ok(())
    }

    fn cycle_paragraph_status(&mut self) {
        // 1.2.4+: when the tree has a multi-select set, apply
        // the cycle to every marked paragraph instead of just
        // the open one.
        if !self.tree_marked.is_empty() {
            let ids: Vec<Uuid> = self.tree_marked.iter().copied().collect();
            let mut ok = 0usize;
            let mut fail = 0usize;
            for id in &ids {
                if self.cycle_status_single(*id).is_ok() {
                    ok += 1;
                } else {
                    fail += 1;
                }
            }
            self.reload_hierarchy();
            self.status = if fail == 0 {
                format!("status cycled on {ok} paragraph(s)")
            } else {
                format!("status cycled on {ok} · {fail} failed")
            };
            return;
        }
        // 1.2.4+: when invoked without an open paragraph (e.g.
        // from the tree pane via `O`), fall back to the
        // cursor's row. Lets tree O cycle status without
        // first having to open the paragraph.
        let id = match self.opened.as_ref().map(|d| d.id) {
            Some(id) => id,
            None => match self.rows.get(self.tree_cursor) {
                Some((id, _)) => *id,
                None => {
                    self.status = "no paragraph selected".into();
                    return;
                }
            },
        };
        let Some(node) = self.hierarchy.get(id).cloned() else {
            self.status = "couldn't find the target paragraph in the hierarchy".into();
            return;
        };
        if node.kind != NodeKind::Paragraph {
            self.status =
                format!("status cycle: `{}` is not a paragraph", node.title);
            return;
        }
        let next = next_status(node.status.as_deref());
        let mut updated = node.clone();
        updated.status = if next == "None" {
            None
        } else {
            Some(next.to_string())
        };
        // Manual cycle clears the auto-promote bookkeeping so a
        // future save that's still above target will re-promote
        // from whichever status the user just rolled into.
        updated.target_hit_at_status = None;
        if let Err(e) = self
            .store
            .raw()
            .update_metadata(id, updated.to_json())
        {
            self.status = format!("status update failed: {e}");
            return;
        }
        // Refresh hierarchy so the status reads back next frame.
        self.reload_hierarchy();
        // Progress event log — feeds the status-ladder bar in
        // Ctrl+V G and the "promoted-to-Final this week" counts.
        let from_label = display_status(node.status.as_deref()).to_ascii_lowercase();
        let to_label = next.to_ascii_lowercase();
        let total_words = node.word_count.max(0) as i64;
        let book_id = self.book_of_node(id);
        crate::progress::record_status_change(
            id, book_id, &from_label, &to_label, total_words,
        );
        // hook.on_status_promoted ( uuid from_status to_status -- )
        // Fires on every transition — manual cycles AND the
        // auto-promote path. Scripts that want to act only on
        // promotions (not demotions / wraps to "none") can
        // inspect the labels.
        crate::scripting::hooks::fire(
            "hook.on_status_promoted",
            vec![
                rust_dynamic::value::Value::from_string(id.to_string()),
                rust_dynamic::value::Value::from_string(from_label.clone()),
                rust_dynamic::value::Value::from_string(to_label.clone()),
            ],
        );
        self.status = format!("status: `{}` → `{}`", display_status(node.status.as_deref()), next);
    }

    /// Open the floating Ctrl+B 1..7 status-filter modal for
    /// `target`, scoped to the tree cursor's enclosing branch.
    fn open_status_filter(&mut self, target: &'static str) {
        let (scope_id, scope_label) = self.resolve_status_filter_scope();
        let entries = self.collect_status_entries(target, scope_id);
        let count = entries.len();
        self.modal = Modal::StatusFilter {
            status_label: target,
            scope: scope_label.clone(),
            entries,
            cursor: 0,
        };
        self.status = format!(
            "status filter [{target}] · {scope_label} · {count} paragraph(s) · R/- cycles · Enter opens"
        );
    }

    /// "Current scope" for the status filter: the cursor's node when
    /// it's a branch; otherwise the nearest non-paragraph ancestor.
    /// Returns `(scope_id, breadcrumb)`. None scope = project-wide.
    fn resolve_status_filter_scope(&self) -> (Option<Uuid>, String) {
        let cursor_id = self
            .rows
            .get(self.tree_cursor)
            .map(|(id, _)| *id);
        let mut cur = cursor_id;
        while let Some(id) = cur {
            let Some(node) = self.hierarchy.get(id) else {
                break;
            };
            if node.kind != NodeKind::Paragraph && node.kind != NodeKind::Image {
                return (Some(id), self.title_breadcrumb(id));
            }
            cur = node.parent_id;
        }
        (None, "entire project".to_string())
    }

    /// Walk every paragraph that is (a) inside `scope_id`'s subtree
    /// when given, and (b) tagged with `target`. Sorted by breadcrumb
    /// so paragraphs from the same parent cluster together.
    fn collect_status_entries(
        &self,
        target: &'static str,
        scope_id: Option<Uuid>,
    ) -> Vec<StatusFilterEntry> {
        let allowed_ids: Option<std::collections::HashSet<Uuid>> =
            scope_id.map(|id| self.hierarchy.collect_subtree(id).into_iter().collect());
        let mut entries: Vec<StatusFilterEntry> = Vec::new();
        for node in self.hierarchy.iter() {
            if node.kind != NodeKind::Paragraph {
                continue;
            }
            if let Some(allowed) = &allowed_ids {
                if !allowed.contains(&node.id) {
                    continue;
                }
            }
            if display_status(node.status.as_deref()) != target {
                continue;
            }
            entries.push(StatusFilterEntry {
                id: node.id,
                title: node.title.clone(),
                breadcrumb: self.title_breadcrumb(node.id),
            });
        }
        entries.sort_by(|a, b| a.breadcrumb.cmp(&b.breadcrumb));
        entries
    }

    fn status_filter_handle_key(&mut self, key: KeyEvent) -> bool {
        // Navigation-only branches first; status cycling has its own
        // path because it needs the full `self` borrow.
        let advance: Option<bool> = match key.code {
            KeyCode::Char('r') | KeyCode::Char('R') => Some(true),
            KeyCode::Char('-') | KeyCode::Backspace => Some(false),
            _ => None,
        };
        if let Some(forward) = advance {
            self.cycle_status_in_filter(forward);
            return true;
        }

        let Modal::StatusFilter { entries, cursor, .. } = &mut self.modal else {
            return false;
        };
        let total = entries.len();
        match key.code {
            KeyCode::Up => {
                if *cursor > 0 {
                    *cursor -= 1;
                }
                true
            }
            KeyCode::Down => {
                if *cursor + 1 < total {
                    *cursor += 1;
                }
                true
            }
            KeyCode::Home => {
                *cursor = 0;
                true
            }
            KeyCode::End => {
                *cursor = total.saturating_sub(1);
                true
            }
            KeyCode::PageUp => {
                *cursor = cursor.saturating_sub(10);
                true
            }
            KeyCode::PageDown => {
                *cursor = (*cursor + 10).min(total.saturating_sub(1));
                true
            }
            KeyCode::Enter => {
                self.commit_status_filter();
                true
            }
            _ => false,
        }
    }

    /// Step the highlighted paragraph's status forward or backward
    /// in the workflow ring (without leaving the modal). The list is
    /// re-collected against the original filter — if the paragraph
    /// no longer matches it disappears and the next row slides up.
    /// The cursor index is clamped to stay inside the (possibly
    /// shorter) list.
    fn cycle_status_in_filter(&mut self, forward: bool) {
        let (target_status, paragraph_id, prior_cursor) = match &self.modal {
            Modal::StatusFilter { status_label, entries, cursor, .. } => {
                let Some(entry) = entries.get(*cursor) else {
                    return;
                };
                (*status_label, entry.id, *cursor)
            }
            _ => return,
        };
        let Some(node) = self.hierarchy.get(paragraph_id).cloned() else {
            return;
        };
        let new_label = if forward {
            next_status(node.status.as_deref())
        } else {
            prev_status(node.status.as_deref())
        };
        let mut updated = node.clone();
        updated.status = if new_label == "None" {
            None
        } else {
            Some(new_label.to_string())
        };
        if let Err(e) = self
            .store
            .raw()
            .update_metadata(paragraph_id, updated.to_json())
        {
            self.status = format!("status update failed: {e}");
            return;
        }
        self.reload_hierarchy();
        // Re-collect entries under the same scope + status target.
        let (scope_id, scope_label) = self.resolve_status_filter_scope();
        let entries = self.collect_status_entries(target_status, scope_id);
        let total = entries.len();
        let new_cursor = if total == 0 {
            0
        } else if prior_cursor >= total {
            total - 1
        } else {
            prior_cursor
        };
        self.modal = Modal::StatusFilter {
            status_label: target_status,
            scope: scope_label.clone(),
            entries,
            cursor: new_cursor,
        };
        let direction = if forward { "→" } else { "←" };
        self.status = format!(
            "{} {direction} {new_label} · `{scope_label}` · {total} paragraph(s) remaining",
            node.title
        );
    }

    fn commit_status_filter(&mut self) {
        let target_id = match &self.modal {
            Modal::StatusFilter { entries, cursor, .. } => {
                entries.get(*cursor).map(|e| e.id)
            }
            _ => None,
        };
        let Some(id) = target_id else {
            self.modal = Modal::None;
            return;
        };
        self.modal = Modal::None;
        // Jump tree cursor to the chosen paragraph, then open it via
        // the standard load path so paragraph_cursors / save sessions
        // work the same as Enter from the tree.
        if let Some(i) = self.rows.iter().position(|(rid, _)| *rid == id) {
            self.tree_cursor = i;
        }
        if let Some(node) = self.hierarchy.get(id).cloned() {
            if let Err(e) = self.load_paragraph(&node) {
                self.status = format!("open: {e}");
            }
        }
    }

    /// Editor meta `Ctrl+B T`: rerun the placeholder-title derivation on
    /// the currently-open paragraph. Same logic that fires on save when
    /// the title is still `PARAGRAPH_PLACEHOLDER_TITLE`, but exposed
    /// explicitly so the user can refresh the tree-display name after
    /// rewriting the lead. Bails out cleanly if no first sentence can be
    /// extracted (paragraph is empty or only contains headings).
    fn rename_paragraph_to_first_sentence(&mut self) {
        let Some(doc) = self.opened.as_ref() else {
            self.status = "no paragraph open".into();
            return;
        };
        let body = doc.textarea.lines().join("\n");
        let Some(new_title) = extract_first_sentence(&body) else {
            self.status =
                "couldn't derive a title — paragraph is empty or only headings".into();
            return;
        };
        let id = doc.id;
        match self.store.rename_node(&self.hierarchy, id, &new_title) {
            Ok(()) => {
                self.reload_hierarchy();
                if let Some(d) = self.opened.as_mut() {
                    d.title = new_title.clone();
                    if let Some(node) = self.hierarchy.get(id) {
                        if let Some(rel) = node.file.as_ref() {
                            d.rel_path = rel.clone();
                        }
                    }
                }
                self.status = format!("renamed paragraph to `{new_title}`");
            }
            Err(e) => {
                self.status = format!("rename failed: {e}");
            }
        }
    }

    fn quickref_handle_key(&mut self, key: KeyEvent) -> bool {
        let Modal::QuickRef { focus, scroll } = &mut self.modal else {
            return false;
        };
        let total = quickref::entries_for(*focus).len();
        match key.code {
            KeyCode::Up => {
                *scroll = scroll.saturating_sub(1);
                true
            }
            KeyCode::Down => {
                if *scroll + 1 < total {
                    *scroll += 1;
                }
                true
            }
            KeyCode::PageUp => {
                *scroll = scroll.saturating_sub(10);
                true
            }
            KeyCode::PageDown => {
                *scroll = (*scroll + 10).min(total.saturating_sub(1));
                true
            }
            KeyCode::Home => {
                *scroll = 0;
                true
            }
            KeyCode::End => {
                *scroll = total.saturating_sub(1);
                true
            }
            _ => false,
        }
    }

    /// Scroll handler for the Credits modal — mirrors `quickref_handle_key`.
    /// `total` is the number of rendered lines (computed in the renderer),
    /// but we don't need a hard upper bound here; clamping happens at
    /// render time so out-of-range scroll just shows a blank tail.
    fn credits_handle_key(&mut self, key: KeyEvent) -> bool {
        let Modal::Credits { scroll, .. } = &mut self.modal else {
            return false;
        };
        match key.code {
            KeyCode::Up => {
                *scroll = scroll.saturating_sub(1);
                true
            }
            KeyCode::Down => {
                *scroll = scroll.saturating_add(1);
                true
            }
            KeyCode::PageUp => {
                *scroll = scroll.saturating_sub(10);
                true
            }
            KeyCode::PageDown => {
                *scroll = scroll.saturating_add(10);
                true
            }
            KeyCode::Home => {
                *scroll = 0;
                true
            }
            KeyCode::End => {
                *scroll = usize::MAX / 2;
                true
            }
            _ => false,
        }
    }

    fn open_file_picker(&mut self, context: PickerContext) {
        let root = std::env::current_dir().unwrap_or_else(|_| self.layout.root.clone());
        self.modal = Modal::FilePicker(FilePicker::new(root, context));
    }

    fn commit_file_pick(&mut self) {
        let (path, is_dir, context) = match &self.modal {
            Modal::FilePicker(p) => match p.current() {
                Some(entry) => (entry.path.clone(), entry.is_dir, p.context),
                None => {
                    self.modal = Modal::None;
                    return;
                }
            },
            _ => return,
        };

        match (context, is_dir) {
            (PickerContext::EditorLoad, true) => {
                self.status =
                    "Editor F3 needs a file, not a directory — Enter on a file".into();
            }
            (PickerContext::EditorLoad, false) => {
                self.load_file_into_editor(&path);
                self.modal = Modal::None;
            }
            (PickerContext::TreeInsertOrImport, false) => {
                self.import_single_file(&path);
                self.modal = Modal::None;
            }
            (PickerContext::TreeInsertOrImport, true) => {
                // Defer the actual import to the main loop so it can run
                // with a progress splash drawn directly via the terminal
                // handle. `commit_file_pick` doesn't own the terminal —
                // see `App::run`.
                self.modal = Modal::None;
                self.pending_import = Some(path);
            }
        }
    }

    fn import_single_file(&mut self, path: &std::path::Path) {
        // Route image files (PNG / JPG / etc.) to the image-import
        // path; everything else gets the prose treatment.
        if let Some(ext) = image_extension_for(path) {
            self.import_single_image(path, &ext);
            return;
        }
        // Place the new paragraph after the tree cursor's same-kind ancestor
        // if there is one (so it's "insert after current"). Falls back to
        // append-at-end under the nearest valid parent.
        let cursor_id = self.rows.get(self.tree_cursor).map(|(id, _)| *id);
        let anchor = cursor_id.and_then(|id| {
            let mut cur = Some(id);
            while let Some(c) = cur {
                let node = self.hierarchy.get(c)?;
                if node.kind == NodeKind::Paragraph {
                    return Some(node.id);
                }
                cur = node.parent_id;
            }
            None
        });
        let position = match anchor {
            Some(id) => InsertPosition::After(id),
            None => InsertPosition::End,
        };
        let parent = match position {
            InsertPosition::After(anchor_id) | InsertPosition::Before(anchor_id) => self
                .hierarchy
                .get(anchor_id)
                .and_then(|n| n.parent_id)
                .and_then(|pid| self.hierarchy.get(pid))
                .cloned(),
            InsertPosition::End => {
                match self
                    .hierarchy
                    .pick_parent_for(&self.cfg, cursor_id, NodeKind::Paragraph)
                {
                    Ok(p) => p.cloned(),
                    Err(e) => {
                        self.status = format!("can't import here: {e}");
                        return;
                    }
                }
            }
        };

        let title = derive_paragraph_title_from_path(path);
        let bytes = match std::fs::read(path) {
            Ok(b) => b,
            Err(e) => {
                self.status = format!("read {}: {e}", path.display());
                return;
            }
        };

        let content_type = content_type_for(path);
        let mut created = match self.store.create_node(
            &self.cfg,
            &self.hierarchy,
            NodeKind::Paragraph,
            &title,
            parent.as_ref(),
            None,
            position,
        ) {
            Ok(n) => n,
            Err(e) => {
                self.status = format!("create paragraph: {e}");
                return;
            }
        };
        // For non-default content types, stamp the node + rename the
        // on-disk file so the extension matches (`<NN>-<slug>.hjson`
        // instead of `.typ`). create_node always lays down the file
        // with the typst extension; we move it before writing bytes.
        if let Some(ct) = &content_type {
            created.content_type = Some(ct.clone());
            let new_rel = std::path::PathBuf::from(
                created.file.clone().unwrap_or_default(),
            )
            .with_extension(ct);
            if let Some(old_rel) = &created.file {
                let old_abs = self.layout.root.join(old_rel);
                let new_abs = self.layout.root.join(&new_rel);
                if old_abs.exists() && old_abs != new_abs {
                    let _ = std::fs::rename(&old_abs, &new_abs);
                }
            }
            created.file = Some(new_rel.to_string_lossy().into_owned());
            if let Err(e) = self
                .store
                .raw()
                .update_metadata(created.id, created.to_json())
            {
                self.status = format!("update metadata: {e}");
                return;
            }
        }

        // Replace the templated body with the actual file content.
        let Some(rel) = created.file.clone() else {
            self.status = "created paragraph has no file path — bug?".into();
            return;
        };
        let abs = self.layout.root.join(&rel);
        if let Err(e) = std::fs::write(&abs, &bytes) {
            self.status = format!("write {}: {e}", abs.display());
            return;
        }
        let mut node = created.clone();
        if let Err(e) = self.store.update_paragraph_content(&mut node, &bytes) {
            self.status = format!("update: {e}");
            return;
        }
        let _ = self.store.sync();
        let kind_note = match content_type.as_deref() {
            Some("hjson") => " (hjson)",
            _ => "",
        };
        self.status = format!("imported `{}` as paragraph{kind_note}", path.display());
        self.reload_hierarchy();
        if let Some(i) = self.rows.iter().position(|(rid, _)| *rid == created.id) {
            self.tree_cursor = i;
        }
    }

    /// Sibling to `import_single_file` for image files. The parent
    /// selection mirrors the prose path: insert after the cursor's
    /// nearest leaf sibling, falling back to "append at the end of
    /// the nearest legal branch".
    fn import_single_image(&mut self, path: &std::path::Path, ext: &str) {
        let bytes = match std::fs::read(path) {
            Ok(b) => b,
            Err(e) => {
                self.status = format!("read {}: {e}", path.display());
                return;
            }
        };
        let title = derive_paragraph_title_from_path(path);
        let cursor_id = self.rows.get(self.tree_cursor).map(|(id, _)| *id);
        let anchor = cursor_id.and_then(|id| {
            let mut cur = Some(id);
            while let Some(c) = cur {
                let node = self.hierarchy.get(c)?;
                if node.kind.is_leaf() {
                    return Some(node.id);
                }
                cur = node.parent_id;
            }
            None
        });
        let position = match anchor {
            Some(id) => InsertPosition::After(id),
            None => InsertPosition::End,
        };
        let parent = match position {
            InsertPosition::After(anchor_id) | InsertPosition::Before(anchor_id) => self
                .hierarchy
                .get(anchor_id)
                .and_then(|n| n.parent_id)
                .and_then(|pid| self.hierarchy.get(pid))
                .cloned(),
            InsertPosition::End => {
                match self
                    .hierarchy
                    .pick_parent_for(&self.cfg, cursor_id, NodeKind::Image)
                {
                    Ok(p) => p.cloned(),
                    Err(e) => {
                        self.status = format!("can't import image here: {e}");
                        return;
                    }
                }
            }
        };
        let created = match self.store.create_image_node(
            &self.cfg,
            &self.hierarchy,
            &title,
            ext,
            &bytes,
            parent.as_ref(),
            position,
        ) {
            Ok(n) => n,
            Err(e) => {
                self.status = format!("import image: {e}");
                return;
            }
        };
        self.status = format!(
            "imported `{}` as image ({} bytes)",
            path.display(),
            bytes.len()
        );
        self.reload_hierarchy();
        if let Some(i) = self.rows.iter().position(|(rid, _)| *rid == created.id) {
            self.tree_cursor = i;
        }
    }

    /// Drive a deferred directory import set up by `commit_file_pick`.
    /// Pre-counts files so the splash has a meaningful denominator, then
    /// runs the (synchronous) import with a progress callback that
    /// throttles `terminal.draw` to ~30 Hz. Status bar is updated when
    /// the import finishes; the next mainloop frame paints over the
    /// splash automatically.
    fn run_pending_import<B: ratatui::backend::Backend>(
        &mut self,
        terminal: &mut Terminal<B>,
        root: &Path,
    ) {
        let total = count_importable_files(root);
        let source_display = root.display().to_string();
        let progress_root = root.to_path_buf();

        // Initial 0/total frame so the splash appears even if the
        // first file takes a moment (e.g. fastembed cold-loading on
        // first import in a session).
        let _ = terminal.draw(|f| {
            draw_import_splash(f, &source_display, 0, total, "scanning…")
        });

        let mut last_redraw = std::time::Instant::now();
        {
            let source_display = source_display.clone();
            let mut progress = move |done: usize, file: &Path| {
                if last_redraw.elapsed() < std::time::Duration::from_millis(33) {
                    return;
                }
                last_redraw = std::time::Instant::now();
                let rel = file
                    .strip_prefix(&progress_root)
                    .unwrap_or(file);
                let label = rel.display().to_string();
                let _ = terminal.draw(|f| {
                    draw_import_splash(f, &source_display, done, total, &label)
                });
            };
            self.import_directory_tree(root, &mut progress);
        }
    }

    /// Ctrl+B A — resolve "current book" the same way Ctrl+B I does
    /// (open paragraph's book, or the tree-cursor's book), validate
    /// it's a user book, then stash the uuid so the main loop drives
    /// the assembly with the splash.
    fn schedule_assembly(&mut self) {
        // 1.2.5+: assembly walks the on-disk .typ files. If the
        // user has unsaved edits in the editor (primary or
        // secondary), assemble would see stale bytes. Save first
        // so what the user sees in the editor is what hits the
        // assembler.
        self.save_all_before_build_step("Book assembly");
        let hierarchy = match Hierarchy::load(&self.store) {
            Ok(h) => h,
            Err(e) => {
                self.status = format!("Book assembly: hierarchy load failed: {e}");
                return;
            }
        };
        let Some(book) = self.current_book_node(&hierarchy) else {
            self.status =
                "Book assembly: move the tree cursor onto a user book (or any node inside one) first."
                    .into();
            return;
        };
        if book.system_tag.is_some() {
            self.status = format!(
                "Book assembly: `{}` is a system book — pick a user book.",
                book.title
            );
            return;
        }
        self.pending_assembly = Some(book.id);
        self.status = format!("Book assembly: assembling `{}`…", book.title);
    }

    /// Drive a deferred Book assembly. Pre-renders the splash at 0%,
    /// runs the synchronous assembler with a 30 Hz-throttled progress
    /// callback, and surfaces the final result (root .typ path or
    /// error) in the status bar.
    fn run_pending_assembly<B: ratatui::backend::Backend>(
        &mut self,
        terminal: &mut Terminal<B>,
        book_id: Uuid,
    ) {
        let book = match Hierarchy::load(&self.store) {
            Ok(h) => match h.get(book_id).cloned() {
                Some(n) => n,
                None => {
                    self.status = "Book assembly: book vanished from hierarchy".into();
                    return;
                }
            },
            Err(e) => {
                self.status = format!("Book assembly: hierarchy load failed: {e}");
                return;
            }
        };

        let book_display = book.title.clone();
        let initial_total = 0;
        let _ = terminal.draw(|f| {
            draw_assembly_splash(f, &book_display, 0, initial_total, "preparing…")
        });

        let mut last_redraw = std::time::Instant::now();
        let book_display_for_cb = book_display.clone();
        let report = {
            let mut progress = move |done: usize, total: usize, file: &Path| {
                if last_redraw.elapsed() < std::time::Duration::from_millis(33) {
                    return;
                }
                last_redraw = std::time::Instant::now();
                let label = file.display().to_string();
                let _ = terminal.draw(|f| {
                    draw_assembly_splash(f, &book_display_for_cb, done, total, &label)
                });
            };
            crate::assemble::assemble_book(
                &self.store,
                &self.layout,
                &self.cfg,
                &book,
                &mut progress,
            )
        };

        match report {
            Ok(r) => {
                self.status = format!(
                    "Book assembly: wrote {} files · root: {}  (typst compile `{}`)",
                    r.files_written,
                    r.root_typ.display(),
                    r.root_typ.display(),
                );
                // hook.on_assemble ( uuid slug root_typ_path files_written -- )
                // Fired after a successful Ctrl+B A. Lets scripts
                // post-process the assembled tree (e.g. patch the
                // root .typ, copy artefacts, kick off a custom
                // build pipeline).
                crate::scripting::hooks::fire(
                    "hook.on_assemble",
                    vec![
                        rust_dynamic::value::Value::from_string(book.id.to_string()),
                        rust_dynamic::value::Value::from_string(book.slug.clone()),
                        rust_dynamic::value::Value::from_string(
                            r.root_typ.to_string_lossy().into_owned(),
                        ),
                        rust_dynamic::value::Value::from_int(r.files_written as i64),
                    ],
                );
            }
            Err(e) => {
                self.status = format!("Book assembly failed: {e}");
            }
        }
    }

    /// Ctrl+B B — schedule a Book "build": assembly + `typst compile`.
    /// On error the build path opens a fresh AI chat for analysis.
    fn schedule_build(&mut self) {
        // 1.2.5+: flush unsaved edits before assemble fires (see
        // schedule_assembly for the rationale).
        self.save_all_before_build_step("Book build");
        let Some(book_id) = self.resolve_current_user_book("Book build") else {
            return;
        };
        self.pending_build = Some(book_id);
        self.status = "Book build: assembling + compiling…".into();
    }

    /// Ctrl+B O — schedule a Book "take": build, then copy the PDF
    /// into the launch cwd with a timestamped filename.
    fn schedule_take(&mut self) {
        // 1.2.5+: flush unsaved edits before assemble fires (see
        // schedule_assembly for the rationale).
        self.save_all_before_build_step("Take the book");
        let Some(book_id) = self.resolve_current_user_book("Take the book") else {
            return;
        };
        self.pending_take = Some(book_id);
        self.status = "Take the book: assembling + compiling + copying…".into();
    }

    /// 1.2.5+: shared autosave step for Ctrl+B A / B / O. Saves
    /// the primary editor and, when similar-paragraph mode has
    /// a secondary editor open, that one too. Errors are logged
    /// at WARN and stamped on the status bar but never abort
    /// the build — the user can react to a save failure by
    /// dismissing the splash on Esc, which still happens. The
    /// helper is a no-op when neither buffer is dirty.
    fn save_all_before_build_step(&mut self, ctx: &str) {
        if let Some(doc) = self.opened.as_ref() {
            if doc.dirty {
                if let Err(e) = self.save_current() {
                    tracing::warn!(
                        target: "inkhaven::build",
                        "{ctx}: primary autosave failed: {e}",
                    );
                    self.status = format!("{ctx}: autosave failed: {e}");
                }
            }
        }
        // Mirror the autosave loop's `Option::take()` dance so we
        // can call `save_doc(&mut self, &mut OpenedDoc)` without
        // an aliasing borrow.
        if let Some(mut doc) = self.secondary.take() {
            if doc.dirty {
                if let Err(e) = self.save_doc(&mut doc) {
                    tracing::warn!(
                        target: "inkhaven::build",
                        "{ctx}: secondary autosave failed: {e}",
                    );
                }
            }
            self.secondary = Some(doc);
        }
    }

    /// Common preflight for Ctrl+B A / B / O. Returns the uuid of the
    /// user book the cursor is inside, or surfaces an error status and
    /// returns None when the cursor isn't on a user book.
    fn resolve_current_user_book(&mut self, ctx: &str) -> Option<Uuid> {
        let hierarchy = match Hierarchy::load(&self.store) {
            Ok(h) => h,
            Err(e) => {
                self.status = format!("{ctx}: hierarchy load failed: {e}");
                return None;
            }
        };
        let book = self.current_book_node(&hierarchy)?;
        if book.system_tag.is_some() {
            self.status =
                format!("{ctx}: `{}` is a system book — pick a user book.", book.title);
            return None;
        }
        Some(book.id)
    }

    /// Run assembly + typst compile for `book_id`. If `take` is true,
    /// the resulting PDF is also copied into the launch cwd with a
    /// timestamped filename. A typst-error opens a fresh AI chat with
    /// the configured error-system-prompt; the user gets streamed
    /// analysis on the AI pane without any extra keystroke.
    fn run_pending_build<B: ratatui::backend::Backend>(
        &mut self,
        terminal: &mut Terminal<B>,
        book_id: Uuid,
        take: bool,
    ) {
        // Step 1: assembly (re-uses the existing splash + procedure).
        self.run_pending_assembly(terminal, book_id);
        // run_pending_assembly returns silently on failure — figure
        // out whether it succeeded by re-checking the artefacts path.
        let book = match Hierarchy::load(&self.store) {
            Ok(h) => match h.get(book_id).cloned() {
                Some(n) => n,
                None => {
                    self.status =
                        "Book build aborted: book vanished mid-assembly".into();
                    return;
                }
            },
            Err(e) => {
                self.status = format!("Book build aborted: hierarchy reload: {e}");
                return;
            }
        };
        let artefacts_root = self.store.resolve_artefacts_dir(&self.cfg);
        let root_typ = artefacts_root
            .join(&book.slug)
            .join(format!("{}.typ", book.slug));
        if !root_typ.is_file() {
            // Assembly didn't produce a root .typ — its status message
            // already explains why, leave it in place.
            return;
        }

        // Step 2: spawn `typst compile` and animate the splash while
        // the child runs.
        let book_display = book.title.clone();
        let outcome = match self.run_typst_compile(terminal, &book_display, &root_typ) {
            Some(o) => o,
            None => return, // spawn failed; status already set
        };

        if outcome.success {
            let pdf_msg = format!(
                "Build OK · PDF: {}",
                outcome.pdf_path.display()
            );
            if take {
                match self.take_book_pdf(&book, &outcome.pdf_path) {
                    Ok(dest) => {
                        // Multi-format extras live next to the PDF
                        // with matching stem. Errors are reported
                        // (status bar) but never abort the take —
                        // the PDF the user actually asked for is
                        // already on disk.
                        let extras = self.take_extra_formats(terminal, &book, &dest);
                        let extras_msg = if extras.is_empty() {
                            String::new()
                        } else {
                            format!(" · extras: {}", extras.join(", "))
                        };
                        self.status = format!(
                            "Took the book · {}{}  (source PDF: {})",
                            dest.display(),
                            extras_msg,
                            outcome.pdf_path.display()
                        );
                        // hook.on_take ( uuid slug pdf_dest -- )
                        // Fired once the PDF is copied to the
                        // launch cwd (and any configured extra
                        // formats are written alongside). Lets
                        // scripts upload the artefact, post a
                        // chat notification, etc.
                        crate::scripting::hooks::fire(
                            "hook.on_take",
                            vec![
                                rust_dynamic::value::Value::from_string(
                                    book.id.to_string(),
                                ),
                                rust_dynamic::value::Value::from_string(
                                    book.slug.clone(),
                                ),
                                rust_dynamic::value::Value::from_string(
                                    dest.to_string_lossy().into_owned(),
                                ),
                            ],
                        );
                    }
                    Err(e) => {
                        self.status = format!("{pdf_msg} · take failed: {e}");
                    }
                }
            } else {
                self.status = pdf_msg;
            }
            return;
        }

        // Compile failed — surface the stderr through the AI pane and
        // leave a status hint mentioning where to read the answer.
        let error_text = if outcome.stderr.trim().is_empty() {
            outcome.stdout.clone()
        } else {
            outcome.stderr.clone()
        };
        self.start_typst_error_analysis(&book, &root_typ, &error_text);
    }

    /// Drive a typst-compile child to completion with the spinner
    /// splash. Returns the outcome on success-or-failure of the
    /// compile itself; returns None when even spawning the binary
    /// failed (status bar is set in that case).
    fn run_typst_compile<B: ratatui::backend::Backend>(
        &mut self,
        terminal: &mut Terminal<B>,
        book_display: &str,
        root_typ: &Path,
    ) -> Option<crate::typst_compile::CompileOutcome> {
        let mut handle = match crate::typst_compile::spawn_with_config(&self.cfg, root_typ) {
            Ok(h) => h,
            Err(e) => {
                self.status = format!("typst compile: {e}");
                return None;
            }
        };
        // Animate the splash while the compile runs (external child
        // or in-process worker thread — same loop, same UX).
        // ~80ms per frame keeps the spinner readable without
        // burning CPU. The same loop also polls for Esc so a stuck
        // compile can be interrupted by the user.
        let engine_label = crate::typst_compile::engine_summary(&self.cfg);
        let started = std::time::Instant::now();
        let mut spin_idx: usize = 0;
        let mut cancelled = false;
        loop {
            let elapsed = started.elapsed().as_secs();
            let spinner = TYPST_COMPILE_SPINNER[spin_idx % TYPST_COMPILE_SPINNER.len()];
            let _ = terminal.draw(|f| {
                draw_typst_compile_splash(
                    f,
                    book_display,
                    &engine_label,
                    elapsed,
                    spinner,
                    None,
                )
            });
            spin_idx = spin_idx.wrapping_add(1);
            // Poll for input WITHOUT consuming non-Esc keys — we
            // re-emit nothing here; any user typing during the
            // compile is just dropped (the alternate-screen
            // splash is modal). Esc → cancel.
            if let Ok(true) = crossterm::event::poll(std::time::Duration::from_millis(0)) {
                if let Ok(crossterm::event::Event::Key(k)) = crossterm::event::read() {
                    if matches!(k.code, crossterm::event::KeyCode::Esc) {
                        handle.kill();
                        cancelled = true;
                        break;
                    }
                }
            }
            match handle.try_wait() {
                Ok(Some(_)) => break,
                Ok(None) => {
                    std::thread::sleep(std::time::Duration::from_millis(80));
                }
                Err(e) => {
                    self.status = format!("typst compile: try_wait: {e}");
                    return None;
                }
            }
        }
        let outcome = match crate::typst_compile::finish(handle) {
            Ok(o) => {
                if cancelled {
                    // Promote the outcome's failure state into a
                    // user-visible "cancelled" message rather than
                    // running the AI error-analysis path.
                    self.status = format!(
                        "typst compile cancelled — partial output (if any) discarded · engine: {engine_label}",
                    );
                    return None;
                }
                o
            }
            Err(e) => {
                self.status = format!("typst compile: {e}");
                return None;
            }
        };

        // 1.2.6+: hold the splash on screen with a "Press any key to
        // continue…" prompt so the user can read the result line
        // before the editor regains the screen. Toggle:
        // `typst_compile.wait_for_key_after_compile` (default true).
        // Cancelled compiles already returned above and skip this.
        if self.cfg.typst_compile.wait_for_key_after_compile {
            let final_elapsed = started.elapsed().as_secs();
            let final_spinner =
                TYPST_COMPILE_SPINNER[spin_idx % TYPST_COMPILE_SPINNER.len()];
            let done = Some(outcome.success);
            let _ = terminal.draw(|f| {
                draw_typst_compile_splash(
                    f,
                    book_display,
                    &engine_label,
                    final_elapsed,
                    final_spinner,
                    done,
                )
            });
            // Block on a single key event. Drain any non-key events
            // (mouse, resize) so a stray scroll wheel doesn't sneak
            // through and dismiss the splash. Resize triggers a
            // redraw and keeps waiting.
            loop {
                match crossterm::event::read() {
                    Ok(crossterm::event::Event::Key(_)) => break,
                    Ok(crossterm::event::Event::Resize(_, _)) => {
                        let _ = terminal.draw(|f| {
                            draw_typst_compile_splash(
                                f,
                                book_display,
                                &engine_label,
                                final_elapsed,
                                final_spinner,
                                done,
                            )
                        });
                    }
                    Ok(_) => {}
                    Err(_) => break,
                }
            }
        }

        Some(outcome)
    }

    /// Write each format in `cfg.output.extra_formats` alongside
    /// `pdf_dest`, sharing the same stem. The combined `.typ`
    /// source is built once from `book`'s subtree and reused for
    /// every format. Per-format errors are logged and surfaced in
    /// the returned brief list (`["markdown", "tex error: …"]`),
    /// never aborting — the PDF the user asked for is already on
    /// disk before this fires.
    fn take_extra_formats<B: ratatui::backend::Backend>(
        &self,
        terminal: &mut Terminal<B>,
        book: &crate::store::node::Node,
        pdf_dest: &Path,
    ) -> Vec<String> {
        let formats = &self.cfg.output.extra_formats;
        if formats.is_empty() {
            return Vec::new();
        }
        let hierarchy = match crate::store::hierarchy::Hierarchy::load(&self.store) {
            Ok(h) => h,
            Err(e) => {
                tracing::warn!(
                    target: "inkhaven::take",
                    "extra formats: hierarchy load: {e}",
                );
                return vec![format!("hierarchy load failed: {e}")];
            }
        };
        let layout = crate::project::ProjectLayout::new(self.store.project_root());
        let combined = match crate::export::assemble_typst_source(
            &layout,
            &hierarchy,
            Some(book.id),
        ) {
            Ok(s) => s,
            Err(e) => {
                tracing::warn!(
                    target: "inkhaven::take",
                    "extra formats: assemble: {e}",
                );
                return vec![format!("assemble failed: {e}")];
            }
        };
        let book_display = book.title.clone();
        let formats_display: Vec<String> = formats.iter().map(|s| s.trim().to_string()).collect();
        let mut statuses: Vec<char> = vec!['·'; formats_display.len()];
        let mut produced: Vec<String> = Vec::new();
        // 1.2.6+ — draw the splash with a brief per-format
        // pause so the user can actually SEE which format is
        // being written. Without the pause the loop finishes
        // in milliseconds and the splash is invisible.
        let step_pause = std::time::Duration::from_millis(
            self.cfg.output.extras_step_pause_ms,
        );
        for (i, raw) in formats.iter().enumerate() {
            statuses[i] = '▶';
            let _ = terminal.draw(|f| {
                draw_take_extras_splash(
                    f,
                    &book_display,
                    i,
                    &formats_display,
                    &statuses,
                )
            });
            if !step_pause.is_zero() {
                std::thread::sleep(step_pause);
            }
            let fmt = raw.trim().to_ascii_lowercase();
            // 1.3.0 PDF-1 — `imposed_pdf` operates on the just-built PDF,
            // not the `.typ` source, so it's handled out of band.
            if fmt == "imposed_pdf" {
                match self.take_imposed_pdf(pdf_dest) {
                    Ok(name) => {
                        statuses[i] = '✓';
                        produced.push(name);
                    }
                    Err(e) => {
                        tracing::warn!(target: "inkhaven::take", "imposed_pdf: {e}");
                        statuses[i] = '✗';
                        produced.push("imposed_pdf error".into());
                    }
                }
                continue;
            }
            if fmt == "cover_pdf" {
                match self.take_cover_pdf(pdf_dest, &book.title) {
                    Ok(name) => {
                        statuses[i] = '✓';
                        produced.push(name);
                    }
                    Err(e) => {
                        tracing::warn!(target: "inkhaven::take", "cover_pdf: {e}");
                        statuses[i] = '✗';
                        produced.push("cover_pdf error".into());
                    }
                }
                continue;
            }
            // 1.3.1 SUBMISSION-1 — `docx` builds the Shunn Word document
            // from the store (proper chapters / scene breaks / word count),
            // not from the combined typst, so it's handled out of band.
            if fmt == "docx" {
                match self.take_docx(book, pdf_dest) {
                    Ok(name) => {
                        statuses[i] = '✓';
                        produced.push(name);
                    }
                    Err(e) => {
                        tracing::warn!(target: "inkhaven::take", "docx: {e}");
                        statuses[i] = '✗';
                        produced.push("docx error".into());
                    }
                }
                continue;
            }
            let outcome = self.build_one_extra_format(&fmt, &combined, &book.title);
            let artefact = match outcome {
                Some(Ok(art)) => art,
                Some(Err(e)) => {
                    tracing::warn!(
                        target: "inkhaven::take",
                        "extra format {fmt}: {e}",
                    );
                    statuses[i] = '✗';
                    produced.push(format!("{fmt} error"));
                    continue;
                }
                None => {
                    tracing::warn!(
                        target: "inkhaven::take",
                        "extra format {fmt}: unknown — skipped",
                    );
                    statuses[i] = '✗';
                    produced.push(format!("{fmt}?"));
                    continue;
                }
            };
            let dest = crate::export::with_artefact_extension(pdf_dest, &artefact);
            if let Err(e) = artefact.write_to(&dest) {
                tracing::warn!(
                    target: "inkhaven::take",
                    "extra format {fmt} write {}: {e}",
                    dest.display(),
                );
                statuses[i] = '✗';
                produced.push(format!("{fmt} write error"));
                continue;
            }
            statuses[i] = '✓';
            produced.push(dest.file_name()
                .and_then(|s| s.to_str())
                .unwrap_or(&fmt)
                .to_string());
        }
        // Final frame so the user sees all checkmarks. When
        // `typst_compile.wait_for_key_after_compile` is on (the
        // 1.2.6 default), we hold this frame until any key
        // press — exactly the same pattern the compile splash
        // uses. Without the wait, we sleep one more step_pause
        // so even auto-dismiss configs are catchable in a
        // terminal screenshot.
        let _ = terminal.draw(|f| {
            draw_take_extras_splash(
                f,
                &book_display,
                formats_display.len().saturating_sub(1),
                &formats_display,
                &statuses,
            )
        });
        if self.cfg.output.extras_wait_for_key {
            loop {
                match crossterm::event::read() {
                    Ok(crossterm::event::Event::Key(_)) => break,
                    Ok(crossterm::event::Event::Resize(_, _)) => {
                        let _ = terminal.draw(|f| {
                            draw_take_extras_splash(
                                f,
                                &book_display,
                                formats_display.len().saturating_sub(1),
                                &formats_display,
                                &statuses,
                            )
                        });
                    }
                    Ok(_) => {}
                    Err(_) => break,
                }
            }
        } else if !step_pause.is_zero() {
            std::thread::sleep(step_pause);
        }
        produced
    }

    /// 1.3.0 PDF-1 — the `imposed_pdf` book-take format: impose the
    /// just-built PDF with the `output.imposed_pdf_config` profile,
    /// writing `<stem>-imposed.pdf` next to it.  Returns the filename.
    fn take_imposed_pdf(&self, pdf_dest: &Path) -> anyhow::Result<String> {
        use anyhow::anyhow;
        let profile = &self.cfg.output.imposed_pdf_config;
        let params = self.cfg.imposition.resolve(profile).map_err(|e| anyhow!(e))?;
        let src = crate::pdf::PdfDoc::load(pdf_dest)
            .map_err(|e| anyhow!("load {}: {e}", pdf_dest.display()))?;
        let mut imposed = crate::pdf::impose::impose(&src, &params).map_err(|e| anyhow!("{e}"))?;
        let stem = pdf_dest
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or("book");
        let out = pdf_dest.with_file_name(format!("{stem}-imposed.pdf"));
        imposed
            .save(&out)
            .map_err(|e| anyhow!("save {}: {e}", out.display()))?;
        Ok(out
            .file_name()
            .and_then(|s| s.to_str())
            .unwrap_or("imposed.pdf")
            .to_string())
    }

    /// 1.3.0 PDF-1 P2 — the `cover_pdf` book-take format: generate a
    /// cover-and-spine PDF for the just-built book.  The spine width is
    /// computed from the interior PDF's page count + the `cover:` config
    /// paper stocks; title/author come from `book.title` + the PDF
    /// metadata.  Writes `<stem>-cover.pdf` next to the interior.
    fn take_cover_pdf(&self, pdf_dest: &Path, book_title: &str) -> anyhow::Result<String> {
        use anyhow::anyhow;
        let src = crate::pdf::PdfDoc::load(pdf_dest)
            .map_err(|e| anyhow!("load {}: {e}", pdf_dest.display()))?;
        let meta = crate::pdf::meta::read_metadata(&src);
        let title = meta.title.filter(|t| !t.is_empty()).unwrap_or_else(|| {
            if book_title.is_empty() {
                "Untitled".into()
            } else {
                book_title.to_string()
            }
        });
        let req = crate::pdf::cover::CoverRequest {
            page_count: src.page_count(),
            title: Some(title),
            author: meta.author,
            back_text: None,
            front_image: None,
            isbn: None,
            spine_mm_override: None,
        };
        let spec = self.cfg.cover.build_spec(&req);
        let mut cover = crate::pdf::cover::build_cover(&spec).map_err(|e| anyhow!("{e}"))?;
        let stem = pdf_dest
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or("book");
        let out = pdf_dest.with_file_name(format!("{stem}-cover.pdf"));
        cover
            .save(&out)
            .map_err(|e| anyhow!("save {}: {e}", out.display()))?;
        Ok(out
            .file_name()
            .and_then(|s| s.to_str())
            .unwrap_or("cover.pdf")
            .to_string())
    }

    /// 1.3.1 SUBMISSION-1 — the `docx` book-take: build the Shunn Word
    /// document for the just-built book from the store (shared
    /// `build_model`), writing `<stem>.docx` beside the interior PDF.
    /// Title/author default from the book + `editor.comment_author`.
    fn take_docx(&self, book: &crate::store::node::Node, pdf_dest: &Path) -> anyhow::Result<String> {
        use anyhow::anyhow;
        let layout = crate::project::ProjectLayout::new(self.store.project_root());
        let h = crate::store::hierarchy::Hierarchy::load(&self.store)
            .map_err(|e| anyhow!("hierarchy: {e}"))?;
        let (meta, chapters) =
            crate::cli::manuscript::build_model(&layout, &self.cfg, &h, book, None, None, None)
                .map_err(|e| anyhow!("{e}"))?;
        let bytes = crate::export::docx::build_docx(
            &meta,
            &chapters,
            crate::export::docx::DocxFont::TimesNewRoman,
        )
        .map_err(|e| anyhow!("{e}"))?;
        let stem = pdf_dest
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or("book");
        let out = pdf_dest.with_file_name(format!("{stem}.docx"));
        crate::io_atomic::write(&out, &bytes)
            .map_err(|e| anyhow!("write {}: {e}", out.display()))?;
        Ok(out
            .file_name()
            .and_then(|s| s.to_str())
            .unwrap_or("book.docx")
            .to_string())
    }

    fn build_one_extra_format(
        &self,
        fmt: &str,
        combined: &str,
        book_title: &str,
    ) -> Option<anyhow::Result<crate::export::Artefact>> {
        match fmt {
            "markdown" | "md" => Some(Ok(crate::export::build_markdown(combined))),
            "tex" | "latex" => Some(Ok(crate::export::build_tex(combined))),
            "epub" => {
                let md = crate::export::markdown::typst_to_markdown(combined);
                Some(crate::export::build_epub(&md, book_title))
            }
            _ => None,
        }
    }

    /// Copy `pdf_src` into the launch cwd as
    /// `<book-slug>-YYYYDDMM-HHMM.pdf`. Returns the destination path
    /// on success. The cwd is inkhaven's `current_dir()` at the
    /// moment of the call — same path the shell-launched binary saw.
    fn take_book_pdf(
        &self,
        book: &Node,
        pdf_src: &Path,
    ) -> std::io::Result<std::path::PathBuf> {
        let cwd = std::env::current_dir()?;
        let now = chrono::Local::now();
        // Match the existing backup filename style: YYYYDDMM_HHMMSS.
        // User asked for YYYYDDMM-HHMM specifically — slight variant.
        let stamp = now.format("%Y%d%m-%H%M");
        let dest = cwd.join(format!("{}-{stamp}.pdf", book.slug));
        std::fs::copy(pdf_src, &dest)?;
        Ok(dest)
    }

    /// Open a fresh AI chat (cleared history, system prompt tuned for
    /// typst errors, inference mode forced to Full) and auto-send the
    /// compile error so the user gets streamed analysis without an
    /// extra keystroke.
    fn start_typst_error_analysis(
        &mut self,
        book: &Node,
        root_typ: &Path,
        error_text: &str,
    ) {
        // Wipe any in-flight chat so the new system prompt isn't
        // diluted by unrelated turns.
        self.chat_history.clear();
        self.inference = None;
        self.pending_chat_user_msg = None;
        // Force Full mode per the user's spec; auto-reset scope.
        self.inference_mode = InferenceMode::Full;
        self.ai_mode = AiMode::None;

        let system_prompt = self.cfg.typst_compile.resolved_error_system_prompt();
        let user_prompt = format!(
            "Book: `{book_title}` (slug `{slug}`)\n\
             Root file: {root}\n\n\
             `typst compile` failed with the following error. Please diagnose \
             it using the inkhaven file-layout knowledge from the system \
             prompt and tell me the smallest concrete fix.\n\n\
             --- typst stderr ---\n{err}",
            book_title = book.title,
            slug = book.slug,
            root = root_typ.display(),
            err = error_text.trim(),
        );

        let (model, _env_var) = match self.ai.resolve_provider(&self.cfg.llm, None) {
            Ok(pair) => pair,
            Err(e) => {
                self.status = format!("typst error: can't reach LLM ({e})");
                return;
            }
        };
        let model = model.to_string();
        let provider = self.ai.default_provider.clone();
        let rx = spawn_chat_stream(
            self.ai.client.clone(),
            model.clone(),
            Some(system_prompt),
            Vec::new(),
            user_prompt.clone(),
        );
        self.inference = Some(Inference {
            provider: provider.clone(),
            model,
            response: String::new(),
            status: InferenceStatus::Streaming,
            rx,
            started_at: std::time::Instant::now(),
        });
        // Record the user turn so the assistant's reply ends up in
        // chat_history when streaming finishes.
        self.pending_chat_user_msg = Some(user_prompt);
        self.change_focus(Focus::Ai);
        self.status = format!(
            "typst compile failed · {provider} is analysing the error in the AI pane…"
        );
    }

    fn import_directory_tree(
        &mut self,
        root: &std::path::Path,
        progress: &mut dyn FnMut(usize, &Path),
    ) {
        // The top-level dir's "kind" adapts to where the tree cursor sits:
        //   Book      → top dir becomes a Chapter
        //   Chapter   → top dir becomes a Subchapter
        //   Subchapter→ top dir becomes a Subchapter (requires unbounded)
        //   Paragraph → reject
        // Same rule applies at every recursion level: the new branch's kind
        // is "one level deeper than its parent", so a nested directory tree
        // walks down the hierarchy with it.
        let cursor_id = self.rows.get(self.tree_cursor).map(|(id, _)| *id);
        let parent_id = cursor_id.and_then(|id| {
            // If cursor is on a paragraph we walk up to its enclosing branch
            // — easier than telling the user "move first".
            let mut cur = Some(id);
            while let Some(c) = cur {
                let node = self.hierarchy.get(c)?;
                if node.kind != NodeKind::Paragraph {
                    return Some(node.id);
                }
                cur = node.parent_id;
            }
            None
        });

        let mut counts = ImportCounts::default();
        let result = self.import_dir_recursive(root, parent_id, &mut counts, progress);

        // Always reload — even on partial failure the new branches/paragraphs
        // that DID get created should be visible in the tree.
        self.reload_hierarchy();

        match result {
            Ok(()) => {
                self.status = format!(
                    "imported {}: {} branch(es), {} paragraph(s)",
                    root.display(),
                    counts.branches,
                    counts.paragraphs
                );
            }
            Err(e) => {
                self.status = format!(
                    "partial import of {}: {} branch(es), {} paragraph(s) — stopped at: {e}",
                    root.display(),
                    counts.branches,
                    counts.paragraphs
                );
            }
        }
    }

    fn import_dir_recursive(
        &mut self,
        source: &std::path::Path,
        parent_id: Option<Uuid>,
        counts: &mut ImportCounts,
        progress: &mut dyn FnMut(usize, &Path),
    ) -> InkResult<()> {
        // Resolve parent against a freshly loaded hierarchy so prior creates
        // in this import are visible.
        let hierarchy = Hierarchy::load(&self.store)?;
        let parent = parent_id.and_then(|id| hierarchy.get(id).cloned());

        // Decide what kind of branch this directory becomes. None means we've
        // bottomed out under a bounded hierarchy and should flatten files
        // into the current parent instead of failing.
        let kind: Option<NodeKind> = match parent.as_ref().map(|p| p.kind) {
            None => Some(NodeKind::Book),
            Some(NodeKind::Book) => Some(NodeKind::Chapter),
            Some(NodeKind::Chapter) => Some(NodeKind::Subchapter),
            Some(NodeKind::Subchapter) => {
                if self.cfg.hierarchy.unbounded_subchapters {
                    Some(NodeKind::Subchapter)
                } else {
                    None
                }
            }
            Some(NodeKind::Paragraph) | Some(NodeKind::Image) | Some(NodeKind::Script) => {
                return Err(Error::Store(
                    "can't import under a leaf — move cursor to a branch first".into(),
                ));
            }
        };

        let Some(kind) = kind else {
            // Max depth reached. Walk the rest of the subtree and import every
            // file as a paragraph in the current parent. Branches beyond this
            // point are lost (the bounded hierarchy can't represent them),
            // but the prose comes through.
            let pid = parent_id.expect("None parent already handled by kind match");
            return self.flatten_files_into(source, pid, counts, progress);
        };

        let title = source
            .file_name()
            .and_then(|s| s.to_str())
            .unwrap_or("imported")
            .to_string();
        let created = self.store.create_node(
            &self.cfg,
            &hierarchy,
            kind,
            &title,
            parent.as_ref(),
            None,
            InsertPosition::End,
        )?;
        counts.branches += 1;
        let created_id = created.id;

        let children = read_sorted_children(source);

        // Don't bail on the first failing child — record the error but
        // continue so siblings still get imported. The user gets a partial-
        // import status with counts; orphan dirs get reported in the message.
        let mut first_err: Option<Error> = None;
        for child_path in children {
            let res = if child_path.is_dir() {
                self.import_dir_recursive(&child_path, Some(created_id), counts, progress)
            } else {
                self.import_file_as_paragraph_by_id(&child_path, created_id, counts, progress)
            };
            if let Err(e) = res {
                if first_err.is_none() {
                    first_err = Some(e);
                }
            }
        }
        match first_err {
            None => Ok(()),
            Some(e) => Err(e),
        }
    }

    /// Walk `source` recursively and import every regular file as a paragraph
    /// under `parent_id`. Used when we've hit the depth limit and can no
    /// longer create deeper branches.
    fn flatten_files_into(
        &mut self,
        source: &std::path::Path,
        parent_id: Uuid,
        counts: &mut ImportCounts,
        progress: &mut dyn FnMut(usize, &Path),
    ) -> InkResult<()> {
        let mut first_err: Option<Error> = None;
        for entry in walkdir::WalkDir::new(source)
            .sort_by_file_name()
            .follow_links(false)
        {
            let entry = match entry {
                Ok(e) => e,
                Err(e) => {
                    if first_err.is_none() {
                        first_err = Some(Error::Store(format!("walkdir: {e}")));
                    }
                    continue;
                }
            };
            if !entry.file_type().is_file() {
                continue;
            }
            let name = entry.file_name().to_str().unwrap_or("");
            if name.starts_with('.') {
                continue;
            }
            if let Err(e) = self.import_file_as_paragraph_by_id(
                entry.path(),
                parent_id,
                counts,
                progress,
            ) {
                if first_err.is_none() {
                    first_err = Some(e);
                }
            }
        }
        match first_err {
            None => Ok(()),
            Some(e) => Err(e),
        }
    }

    fn import_file_as_paragraph_by_id(
        &mut self,
        file: &std::path::Path,
        parent_id: Uuid,
        counts: &mut ImportCounts,
        progress: &mut dyn FnMut(usize, &Path),
    ) -> InkResult<()> {
        let title = derive_paragraph_title_from_path(file);
        let raw = std::fs::read(file).map_err(Error::Io)?;
        // Normalise line endings so DOS / old-Mac dumps don't keep
        // their `\r` bytes on disk — those survive into the editor
        // load path and render as control glyphs. Only touched when
        // the content decodes as UTF-8; binary payloads are written
        // verbatim and will simply fail to render meaningfully.
        let bytes: Vec<u8> = match std::str::from_utf8(&raw) {
            Ok(text) if text.contains('\r') => text
                .replace("\r\n", "\n")
                .replace('\r', "\n")
                .into_bytes(),
            _ => raw,
        };
        let hierarchy = Hierarchy::load(&self.store)?;
        let parent = hierarchy
            .get(parent_id)
            .cloned()
            .ok_or_else(|| Error::Store(format!("import: parent {parent_id} vanished")))?;
        let created = self.store.create_node(
            &self.cfg,
            &hierarchy,
            NodeKind::Paragraph,
            &title,
            Some(&parent),
            None,
            InsertPosition::End,
        )?;
        if let Some(rel) = &created.file {
            let abs = self.layout.root.join(rel);
            // 1.2.15+ Phase S.4 — atomic write so an
            // interrupted directory import leaves
            // either nothing on disk for this
            // paragraph or its full body, never a
            // half-written file.
            crate::io_atomic::write(&abs, &bytes).map_err(Error::Io)?;
            let mut node = created.clone();
            self.store.update_paragraph_content(&mut node, &bytes)?;
        }
        counts.paragraphs += 1;
        progress(counts.paragraphs, file);
        Ok(())
    }

    fn open_rename_modal(&mut self) {
        let Some(&(id, _)) = self.rows.get(self.tree_cursor) else {
            self.status = "nothing selected to rename".into();
            return;
        };
        let Some(node) = self.hierarchy.get(id) else {
            return;
        };
        if let Some(reason) = self.protected_block_reason(node) {
            self.status = reason;
            return;
        }
        let mut input = TextInput::new();
        for c in node.title.chars() {
            input.insert_char(c);
        }
        self.modal = Modal::Renaming {
            node_id: id,
            kind: node.kind,
            input,
        };
    }

    fn commit_rename(&mut self) {
        let (node_id, new_title) = match &self.modal {
            Modal::Renaming { node_id, input, .. } => {
                (*node_id, input.as_str().trim().to_string())
            }
            _ => return,
        };
        if new_title.is_empty() {
            self.status = "rename: title can't be empty — type one or Esc to cancel".into();
            return;
        }
        match self.store.rename_node(&self.hierarchy, node_id, &new_title) {
            Ok(()) => {
                self.reload_hierarchy();
                // Refresh editor's title + on-disk rel_path if the
                // renamed node is the open one. 1.2.4+: paragraphs
                // rename their file on disk, so the open doc's
                // rel_path needs to track the new slug.
                if let Some(doc) = self.opened.as_mut() {
                    if doc.id == node_id {
                        doc.title = new_title.clone();
                        if let Some(node) = self.hierarchy.get(node_id) {
                            if let Some(rel) = node.file.as_ref() {
                                doc.rel_path = rel.clone();
                            }
                        }
                    }
                }
                self.modal = Modal::None;
                self.status = format!("renamed to `{new_title}`");
            }
            Err(e) => {
                self.status = format!("rename failed: {e}");
            }
        }
    }

    fn open_delete_modal(&mut self) {
        let Some(&(id, _)) = self.rows.get(self.tree_cursor) else {
            self.status = "nothing selected to delete".into();
            return;
        };
        let Some(node) = self.hierarchy.get(id) else {
            return;
        };
        if let Some(reason) = self.protected_block_reason(node) {
            self.status = reason;
            return;
        }
        let ids = self.hierarchy.collect_subtree(id);
        let descendant_count = ids.len().saturating_sub(1);
        self.modal = Modal::Deleting {
            root_id: id,
            root_kind: node.kind,
            title: node.title.clone(),
            descendant_count,
            ids,
        };
    }

    /// Returns `Some(reason)` if the given node (or any ancestor) is a
    /// system-protected node — used to block destructive operations from
    /// the UI and surface a status message to the user. `None` means the
    /// node is fully mutable.
    fn protected_block_reason(&self, node: &Node) -> Option<String> {
        if node.protected {
            return Some(format!(
                "“{}” is a system book — it can't be deleted or renamed",
                node.title
            ));
        }
        // Walk ancestors so a paragraph inside Help is also blocked.
        for anc in self.hierarchy.ancestors(node) {
            if anc.protected && anc.system_tag.as_deref() == Some(crate::store::SYSTEM_TAG_HELP)
            {
                return Some(format!(
                    "“{}” lives inside the read-only Help book",
                    node.title
                ));
            }
        }
        None
    }

    fn handle_modal_key(&mut self, key: KeyEvent) -> Result<bool> {
        if matches!(key.code, KeyCode::Esc) {
            // SnapshotDiff stashes the picker it opened from in
            // `return_to`; popping that back makes Esc behave
            // like "close the diff, stay in the picker".
            if let Modal::SnapshotDiff { return_to, .. } = &mut self.modal {
                let prev = std::mem::replace(return_to.as_mut(), Modal::None);
                self.modal = prev;
                self.status = "diff closed".into();
                return Ok(false);
            }
            // 1.2.5+ SaveRenderedPng follows the same pattern —
            // restore the underlying RenderedPreview so the user
            // doesn't lose their navigation state when they
            // cancel a save.
            if let Modal::SaveRenderedPng { return_to, .. } = &mut self.modal {
                let prev = std::mem::replace(return_to.as_mut(), Modal::None);
                self.modal = prev;
                self.status = "save PNG: cancelled · preview restored".into();
                return Ok(false);
            }
            // Story-view save picker — mirror SaveRenderedPng.
            if let Modal::SaveStoryPng { return_to, .. } = &mut self.modal {
                let prev = std::mem::replace(return_to.as_mut(), Modal::None);
                self.modal = prev;
                self.status = "save story PNG: cancelled · preview restored".into();
                return Ok(false);
            }
            // 1.2.5+ tag-add and tag-delete sub-modals — Esc
            // returns to the TagPicker that opened them.
            if let Modal::TagAddPrompt { return_to, .. } = &mut self.modal {
                let prev = std::mem::replace(return_to.as_mut(), Modal::None);
                self.modal = prev;
                self.status = "tag add: cancelled".into();
                return Ok(false);
            }
            if let Modal::TagDeleteConfirm { return_to, .. } = &mut self.modal {
                let prev = std::mem::replace(return_to.as_mut(), Modal::None);
                self.modal = prev;
                self.status = "tag delete: cancelled".into();
                return Ok(false);
            }
            // 1.2.6+ tag-rename prompt — same return_to pattern.
            if let Modal::TagRenamePrompt { return_to, .. } = &mut self.modal {
                let prev = std::mem::replace(return_to.as_mut(), Modal::None);
                self.modal = prev;
                self.status = "tag rename: cancelled".into();
                return Ok(false);
            }
            // 1.2.6+ — timeline new-event prompt.
            if let Modal::TimelineNewEventPrompt { return_to, .. } = &mut self.modal {
                let prev = std::mem::replace(return_to.as_mut(), Modal::None);
                self.modal = prev;
                self.status = "new event: cancelled".into();
                return Ok(false);
            }
            // 1.2.6+ — timeline edit-event prompt.
            if let Modal::TimelineEditEventPrompt { .. } = &mut self.modal {
                self.modal = Modal::None;
                self.status = "edit event: cancelled".into();
                return Ok(false);
            }
            // 1.3.9 — Esc during a batch editorial fix abandons the rest of
            // the queue (the diff review it's showing is an AiDiffReview).
            if matches!(self.modal, Modal::AiDiffReview { .. }) {
                if let Some(b) = self.editorial_batch.take() {
                    self.modal = Modal::None;
                    self.status = format!(
                        "editorial batch: stopped — {} applied, {} skipped, {} left",
                        b.applied,
                        b.skipped,
                        b.queue.len()
                    );
                    return Ok(false);
                }
            }
            // 1.2.7+ — timeline view: snapshot the per-book
            // state (collapsed tracks, expanded track, zoom,
            // scroll, cursor) into the session cache so the
            // next Ctrl+V Shift+T for this book restores it.
            if matches!(self.modal, Modal::TimelineView { .. }) {
                self.timeline_capture_view_state();
            }
            self.modal = Modal::None;
            return Ok(false);
        }

        let is_adding = matches!(self.modal, Modal::Adding { .. });
        let is_deleting = matches!(self.modal, Modal::Deleting { .. });
        let is_snapshot = matches!(self.modal, Modal::SnapshotPicker { .. });
        let is_renaming = matches!(self.modal, Modal::Renaming { .. });
        let is_file_picker = matches!(self.modal, Modal::FilePicker(_));
        let is_find = matches!(self.modal, Modal::FindReplace { .. });
        let is_replace_review = matches!(self.modal, Modal::ReplaceReview { .. });
        let is_quickref = matches!(self.modal, Modal::QuickRef { .. });
        let is_credits = matches!(self.modal, Modal::Credits { .. });
        let is_book_info = matches!(self.modal, Modal::BookInfo { .. });
        let is_llm_picker = matches!(self.modal, Modal::LlmPicker { .. });
        let is_translation_picker =
            matches!(self.modal, Modal::TranslationLanguagePicker { .. });
        let is_threads_picker =
            matches!(self.modal, Modal::ThreadsPicker { .. });
        let is_thread_weave_view =
            matches!(self.modal, Modal::ThreadWeaveView { .. });
        let is_thread_doctor =
            matches!(self.modal, Modal::ThreadDoctor { .. });
        let is_doctor_panel =
            matches!(self.modal, Modal::DoctorPanel { .. });
        let is_snippet_picker =
            matches!(self.modal, Modal::SnippetPicker { .. });
        let is_journal =
            matches!(self.modal, Modal::Journal { .. });
        let is_tts_voice_picker =
            matches!(self.modal, Modal::TtsVoicePicker { .. });
        let is_reader_pace =
            matches!(self.modal, Modal::ReaderPace { .. });
        let is_comment_editor =
            matches!(self.modal, Modal::CommentEditor { .. });
        let is_comments_panel =
            matches!(self.modal, Modal::CommentsPanel { .. });
        let is_footnote_editor =
            matches!(self.modal, Modal::FootnoteEditor { .. });
        let is_project_goal_modal =
            matches!(self.modal, Modal::ProjectGoalModal { .. });
        let is_style_transfer_picker =
            matches!(self.modal, Modal::StyleTransferPicker { .. });
        let is_image_picker = matches!(self.modal, Modal::ImagePicker { .. });
        let is_function_picker = matches!(self.modal, Modal::FunctionPicker { .. });
        let is_status_filter = matches!(self.modal, Modal::StatusFilter { .. });
        let is_help_query = matches!(self.modal, Modal::HelpQuery { .. });
        let is_chat_search_prompt = matches!(self.modal, Modal::ChatSearchPrompt { .. });
        let is_bund_eval = matches!(self.modal, Modal::BundEval { .. });
        let is_bund_pane = matches!(self.modal, Modal::BundPane { .. });
        let is_script_picker = matches!(self.modal, Modal::ScriptPicker { .. });
        let is_bund_input = matches!(self.modal, Modal::BundInput { .. });
        let is_similar_picker = matches!(self.modal, Modal::SimilarPicker { .. });
        let is_progress = matches!(self.modal, Modal::Progress { .. });
        let is_paragraph_target = matches!(self.modal, Modal::ParagraphTarget { .. });
        let is_save_markdown = matches!(self.modal, Modal::SaveMarkdown { .. });
        let is_snapshot_diff = matches!(self.modal, Modal::SnapshotDiff { .. });
        let is_link_picker = matches!(self.modal, Modal::LinkPicker { .. });
        let is_backlink_picker = matches!(self.modal, Modal::BacklinkPicker { .. });
        let is_bookmark_picker = matches!(self.modal, Modal::BookmarkPicker { .. });
        let is_fuzzy_paragraph_picker = matches!(self.modal, Modal::FuzzyParagraphPicker { .. });
        let is_kill_ring_picker = matches!(self.modal, Modal::KillRingPicker { .. });
        let is_shell_pane = matches!(self.modal, Modal::ShellPane { .. });
        let is_hjson_editor = matches!(self.modal, Modal::HjsonEditor { .. });
        let is_rendered_preview = matches!(self.modal, Modal::RenderedPreview { .. });
        let is_save_rendered_png = matches!(self.modal, Modal::SaveRenderedPng { .. });
        let is_tts_save_as_audio = matches!(self.modal, Modal::TtsSaveAsAudio { .. });
        let is_writing_streak_heatmap = matches!(self.modal, Modal::WritingStreakHeatmap { .. });
        let is_concordance = matches!(self.modal, Modal::Concordance { .. });
        let is_facts_search = matches!(self.modal, Modal::FactsSearch { .. });
        let is_sentence_rhythm = matches!(self.modal, Modal::SentenceRhythm { .. });
        let is_diagnostics_list = matches!(self.modal, Modal::DiagnosticsList { .. });
        let is_ai_diff_review = matches!(self.modal, Modal::AiDiffReview { .. });
        let is_event_picker = matches!(self.modal, Modal::EventPicker { .. });
        let is_timeline_view = matches!(self.modal, Modal::TimelineView { .. });
        let is_timeline_new_event = matches!(self.modal, Modal::TimelineNewEventPrompt { .. });
        let is_timeline_edit_event = matches!(self.modal, Modal::TimelineEditEventPrompt { .. });
        let is_snapshot_annotation = matches!(self.modal, Modal::SnapshotAnnotation { .. });
        let is_tag_picker = matches!(self.modal, Modal::TagPicker { .. });
        let is_tag_add_prompt = matches!(self.modal, Modal::TagAddPrompt { .. });
        let is_tag_delete_confirm = matches!(self.modal, Modal::TagDeleteConfirm { .. });
        let is_tag_rename_prompt = matches!(self.modal, Modal::TagRenamePrompt { .. });
        let is_tag_search_results = matches!(self.modal, Modal::TagSearchResults { .. });
        let is_story_view = matches!(self.modal, Modal::StoryView { .. });
        let is_save_story_png = matches!(self.modal, Modal::SaveStoryPng { .. });

        if is_quickref {
            self.quickref_handle_key(key);
            return Ok(false);
        }
        if is_credits {
            self.credits_handle_key(key);
            return Ok(false);
        }
        if is_book_info {
            self.book_info_handle_key(key);
            return Ok(false);
        }
        if matches!(self.modal, Modal::ImpositionPreview { .. }) {
            self.imposition_preview_handle_key(key);
            return Ok(false);
        }
        if matches!(self.modal, Modal::SubmissionsTracker { .. }) {
            self.submissions_tracker_handle_key(key);
            return Ok(false);
        }
        if matches!(self.modal, Modal::SubmissionGenPicker { .. }) {
            self.submission_gen_handle_key(key);
            return Ok(false);
        }
        if matches!(self.modal, Modal::PlanOutline { .. }) {
            self.plan_outline_handle_key(key);
            return Ok(false);
        }
        if matches!(self.modal, Modal::EditorialPass { .. }) {
            self.editorial_pass_handle_key(key);
            return Ok(false);
        }
        if matches!(self.modal, Modal::StoryBible { .. }) {
            self.story_bible_handle_key(key);
            return Ok(false);
        }
        if is_llm_picker {
            self.llm_picker_handle_key(key);
            return Ok(false);
        }
        if is_translation_picker {
            self.translation_picker_handle_key(key);
            return Ok(false);
        }
        if is_threads_picker {
            self.threads_picker_handle_key(key);
            return Ok(false);
        }
        if is_thread_weave_view {
            self.thread_weave_handle_key(key);
            return Ok(false);
        }
        if is_thread_doctor {
            self.thread_doctor_handle_key(key);
            return Ok(false);
        }
        if is_doctor_panel {
            self.handle_doctor_panel_key(key);
            return Ok(false);
        }
        if is_snippet_picker {
            self.handle_snippet_picker_key(key);
            return Ok(false);
        }
        if is_journal {
            self.handle_journal_key(key);
            return Ok(false);
        }
        if is_tts_voice_picker {
            self.handle_tts_voice_picker_key(key);
            return Ok(false);
        }
        if is_reader_pace {
            self.handle_reader_pace_key(key);
            return Ok(false);
        }
        if is_comment_editor {
            self.comment_editor_handle_key(key);
            return Ok(false);
        }
        if is_comments_panel {
            self.comments_panel_handle_key(key);
            return Ok(false);
        }
        if is_footnote_editor {
            self.footnote_editor_handle_key(key);
            return Ok(false);
        }
        if is_project_goal_modal {
            self.project_goal_handle_key(key);
            return Ok(false);
        }
        if is_style_transfer_picker {
            self.style_transfer_picker_handle_key(key);
            return Ok(false);
        }
        if is_image_picker {
            self.image_picker_handle_key(key);
            return Ok(false);
        }
        if is_function_picker {
            self.function_picker_handle_key(key);
            return Ok(false);
        }
        if is_status_filter {
            self.status_filter_handle_key(key);
            return Ok(false);
        }

        if is_help_query {
            // Enter submits, anything else feeds the input box. Esc was
            // already handled at the top of this function.
            if matches!(key.code, KeyCode::Enter) {
                let query = match &self.modal {
                    Modal::HelpQuery { input } => input.as_str().to_string(),
                    _ => String::new(),
                };
                // 1.2.8+ — push onto history (dedup vs immediate
                // predecessor); reset the navigation cursor.
                let trimmed = query.trim();
                if !trimmed.is_empty()
                    && self.help_query_history.last().map(|s| s.as_str()) != Some(trimmed)
                {
                    self.help_query_history.push(trimmed.to_string());
                }
                self.help_query_history_cursor = None;
                self.modal = Modal::None;
                self.start_help_inference(&query);
                return Ok(false);
            }
            // 1.2.8+ — Up / Down walks help_query_history.  Mirrors
            // the AI-prompt history pattern: None on entry, Up goes
            // to len-1, Down past newest clears.
            match key.code {
                KeyCode::Up => {
                    if !self.help_query_history.is_empty() {
                        let next = match self.help_query_history_cursor {
                            Some(0) => 0,
                            Some(i) => i - 1,
                            None => self.help_query_history.len() - 1,
                        };
                        self.help_query_history_cursor = Some(next);
                        let entry = self.help_query_history[next].clone();
                        if let Modal::HelpQuery { input } = &mut self.modal {
                            input.clear();
                            for c in entry.chars() {
                                input.insert_char(c);
                            }
                        }
                    }
                    return Ok(false);
                }
                KeyCode::Down => {
                    if let Some(cur) = self.help_query_history_cursor {
                        let next = cur + 1;
                        if next >= self.help_query_history.len() {
                            self.help_query_history_cursor = None;
                            if let Modal::HelpQuery { input } = &mut self.modal {
                                input.clear();
                            }
                        } else {
                            self.help_query_history_cursor = Some(next);
                            let entry = self.help_query_history[next].clone();
                            if let Modal::HelpQuery { input } = &mut self.modal {
                                input.clear();
                                for c in entry.chars() {
                                    input.insert_char(c);
                                }
                            }
                        }
                    }
                    return Ok(false);
                }
                _ => {}
            }
            if let Modal::HelpQuery { input } = &mut self.modal {
                handle_text_input_key(input, key);
            }
            return Ok(false);
        }

        if is_bund_eval {
            // Enter runs the expression against Adam; result goes to
            // the status bar. Anything else feeds the input box.
            if matches!(key.code, KeyCode::Enter) {
                let expr = match &self.modal {
                    Modal::BundEval { input } => input.as_str().to_string(),
                    _ => String::new(),
                };
                self.modal = Modal::None;
                match self.scripting_eval(&expr) {
                    Ok(out) => {
                        self.status = format_eval_output(&out, None);
                    }
                    Err(e) => {
                        self.status = format!("bund: eval failed — {e:#}");
                    }
                }
                return Ok(false);
            }
            if let Modal::BundEval { input } = &mut self.modal {
                handle_text_input_key(input, key);
            }
            return Ok(false);
        }

        if is_bund_pane {
            self.bund_pane_handle_key(key);
            return Ok(false);
        }

        if is_script_picker {
            self.script_picker_handle_key(key);
            return Ok(false);
        }

        if is_similar_picker {
            self.similar_picker_handle_key(key);
            return Ok(false);
        }

        if is_progress {
            self.progress_modal_handle_key(key);
            return Ok(false);
        }

        if is_link_picker {
            self.link_picker_handle_key(key);
            return Ok(false);
        }

        if is_backlink_picker {
            self.backlink_picker_handle_key(key);
            return Ok(false);
        }

        if is_bookmark_picker {
            self.bookmark_picker_handle_key(key);
            return Ok(false);
        }

        if is_fuzzy_paragraph_picker {
            self.fuzzy_paragraph_picker_handle_key(key);
            return Ok(false);
        }

        if is_kill_ring_picker {
            self.kill_ring_picker_handle_key(key);
            return Ok(false);
        }

        if is_shell_pane {
            self.shell_pane_handle_key(key);
            return Ok(false);
        }

        if is_hjson_editor {
            self.hjson_editor_handle_key(key);
            return Ok(false);
        }

        if is_snapshot_diff {
            if let Modal::SnapshotDiff { scroll, rows, .. } = &mut self.modal {
                let total = rows.len();
                let page: usize = 12;
                match key.code {
                    KeyCode::Up => *scroll = scroll.saturating_sub(1),
                    KeyCode::Down => {
                        if *scroll + 1 < total {
                            *scroll += 1;
                        }
                    }
                    KeyCode::PageUp => *scroll = scroll.saturating_sub(page),
                    KeyCode::PageDown => {
                        *scroll = (*scroll + page).min(total.saturating_sub(1));
                    }
                    KeyCode::Home => *scroll = 0,
                    KeyCode::End => *scroll = total.saturating_sub(1),
                    _ => {}
                }
            }
            return Ok(false);
        }

        if is_paragraph_target {
            if matches!(key.code, KeyCode::Enter) {
                let raw = match &self.modal {
                    Modal::ParagraphTarget { input } => input.as_str().trim().to_string(),
                    _ => String::new(),
                };
                self.modal = Modal::None;
                self.commit_paragraph_target(&raw);
                return Ok(false);
            }
            if let Modal::ParagraphTarget { input } = &mut self.modal {
                handle_text_input_key(input, key);
            }
            return Ok(false);
        }

        if is_save_markdown {
            if matches!(key.code, KeyCode::Enter) {
                let (body, label, raw) = match &self.modal {
                    Modal::SaveMarkdown { input, body, label } => (
                        body.clone(),
                        label.clone(),
                        input.as_str().to_string(),
                    ),
                    _ => (String::new(), String::new(), String::new()),
                };
                self.modal = Modal::None;
                self.commit_save_markdown(body, label, raw);
                return Ok(false);
            }
            if let Modal::SaveMarkdown { input, .. } = &mut self.modal {
                handle_text_input_key(input, key);
            }
            return Ok(false);
        }

        if is_rendered_preview {
            // Esc is intercepted by the global modal-close
            // handler at the top of this function. Local keys:
            //   ← / →  — navigate pages
            //   S / s — save current page (open picker, mode Single)
            //   A / a — save every page (open picker, mode All)
            //   anything else — swallowed so the editor doesn't see it
            match key.code {
                KeyCode::Left | KeyCode::Up => {
                    if let Modal::RenderedPreview {
                        pages,
                        current_page,
                        ..
                    } = &mut self.modal
                    {
                        if *current_page > 0 {
                            *current_page -= 1;
                            let total = pages.len();
                            let p = &pages[*current_page];
                            self.status = format!(
                                "render ¶ · page {}/{}  · {}×{}",
                                *current_page + 1,
                                total,
                                p.width,
                                p.height,
                            );
                        }
                    }
                }
                KeyCode::Right | KeyCode::Down => {
                    if let Modal::RenderedPreview {
                        pages,
                        current_page,
                        ..
                    } = &mut self.modal
                    {
                        if *current_page + 1 < pages.len() {
                            *current_page += 1;
                            let total = pages.len();
                            let p = &pages[*current_page];
                            self.status = format!(
                                "render ¶ · page {}/{}  · {}×{}",
                                *current_page + 1,
                                total,
                                p.width,
                                p.height,
                            );
                        }
                    }
                }
                KeyCode::Home => {
                    if let Modal::RenderedPreview { current_page, .. } =
                        &mut self.modal
                    {
                        *current_page = 0;
                    }
                }
                KeyCode::End => {
                    if let Modal::RenderedPreview {
                        pages,
                        current_page,
                        ..
                    } = &mut self.modal
                    {
                        *current_page = pages.len().saturating_sub(1);
                    }
                }
                KeyCode::Char('s') | KeyCode::Char('S') => {
                    self.open_save_rendered_png_picker(false);
                }
                KeyCode::Char('a') | KeyCode::Char('A') => {
                    self.open_save_rendered_png_picker(true);
                }
                // 1.2.6+ — +/- live preview zoom. `+` (or `=`,
                // since most US keyboards put `+` over `=`)
                // bumps PPI by 0.5; `-` reduces by 0.5. Range
                // [0.5, 6.0]. Each change re-renders every
                // page at the new factor so the modal can
                // accommodate any paragraph length without
                // re-opening.
                KeyCode::Char('+') | KeyCode::Char('=') => {
                    self.zoom_rendered_preview(0.5);
                }
                KeyCode::Char('-') | KeyCode::Char('_') => {
                    self.zoom_rendered_preview(-0.5);
                }
                _ => {}
            }
            return Ok(false);
        }

        if is_story_view {
            if matches!(key.code, KeyCode::Char('s') | KeyCode::Char('S')) {
                self.open_save_story_png_picker();
            }
            return Ok(false);
        }
        if is_save_story_png {
            if matches!(key.code, KeyCode::Enter) {
                let taken = std::mem::replace(&mut self.modal, Modal::None);
                if let Modal::SaveStoryPng {
                    input,
                    png_bytes,
                    book_title,
                    return_to: _,
                } = taken
                {
                    let raw = input.as_str().to_string();
                    self.commit_save_story_png(&png_bytes, &raw, &book_title);
                }
                return Ok(false);
            }
            if let Modal::SaveStoryPng { input, .. } = &mut self.modal {
                handle_text_input_key(input, key);
            }
            return Ok(false);
        }

        if is_diagnostics_list {
            self.diagnostics_list_handle_key(key);
            return Ok(false);
        }
        if is_ai_diff_review {
            self.ai_diff_review_handle_key(key);
            return Ok(false);
        }
        if is_event_picker {
            self.event_picker_handle_key(key);
            return Ok(false);
        }
        if is_timeline_view {
            self.timeline_view_handle_key(key);
            return Ok(false);
        }
        if is_timeline_new_event {
            self.timeline_new_event_prompt_handle_key(key);
            return Ok(false);
        }
        if is_timeline_edit_event {
            self.timeline_edit_event_prompt_handle_key(key);
            return Ok(false);
        }
        if is_snapshot_annotation {
            if matches!(key.code, KeyCode::Enter) {
                let taken = std::mem::replace(&mut self.modal, Modal::None);
                if let Modal::SnapshotAnnotation {
                    input,
                    parent_id,
                    parent_title,
                    body,
                } = taken
                {
                    let annotation = input.as_str().to_string();
                    self.commit_snapshot_annotation(
                        parent_id,
                        &parent_title,
                        &body,
                        annotation.trim(),
                    );
                }
                return Ok(false);
            }
            if let Modal::SnapshotAnnotation { input, .. } = &mut self.modal {
                handle_text_input_key(input, key);
            }
            return Ok(false);
        }
        if is_tag_picker {
            self.tag_picker_handle_key(key);
            return Ok(false);
        }
        if is_tag_add_prompt {
            self.tag_add_prompt_handle_key(key);
            return Ok(false);
        }
        if is_tag_delete_confirm {
            self.tag_delete_confirm_handle_key(key);
            return Ok(false);
        }
        if is_tag_rename_prompt {
            self.tag_rename_prompt_handle_key(key);
            return Ok(false);
        }
        if is_tag_search_results {
            self.tag_search_results_handle_key(key);
            return Ok(false);
        }

        if is_save_rendered_png {
            // Esc → restore the preview, handled at the top of
            // this function via the `return_to` stash pattern.
            if matches!(key.code, KeyCode::Enter) {
                let taken = std::mem::replace(&mut self.modal, Modal::None);
                if let Modal::SaveRenderedPng {
                    input,
                    body,
                    settings,
                    title,
                    pages,
                    return_to: _,
                } = taken
                {
                    let raw = input.as_str().to_string();
                    self.commit_save_rendered_png(
                        &body, &settings, &raw, &title, pages,
                    );
                }
                return Ok(false);
            }
            if let Modal::SaveRenderedPng { input, .. } = &mut self.modal {
                handle_text_input_key(input, key);
            }
            return Ok(false);
        }

        if is_writing_streak_heatmap {
            // Any key closes — Esc, Enter, anything.
            // No interactions inside; the modal is a
            // read-only viewer.
            self.modal = Modal::None;
            return Ok(false);
        }

        if is_concordance {
            self.concordance_handle_key(key);
            return Ok(false);
        }

        if is_facts_search {
            self.facts_search_handle_key(key);
            return Ok(false);
        }

        if is_sentence_rhythm {
            self.sentence_rhythm_handle_key(key);
            return Ok(false);
        }

        if is_tts_save_as_audio {
            if matches!(key.code, KeyCode::Esc) {
                self.modal = Modal::None;
                self.status = "TTS save: cancelled".into();
                return Ok(false);
            }
            if matches!(key.code, KeyCode::Enter) {
                let taken = std::mem::replace(&mut self.modal, Modal::None);
                if let Modal::TtsSaveAsAudio {
                    input,
                    body,
                    voice,
                    wpm,
                    voice_label,
                } = taken
                {
                    let raw = input.as_str().to_string();
                    self.commit_tts_save_as_audio(
                        &raw,
                        &body,
                        &voice,
                        wpm,
                        &voice_label,
                    );
                }
                return Ok(false);
            }
            if let Modal::TtsSaveAsAudio { input, .. } = &mut self.modal {
                handle_text_input_key(input, key);
            }
            return Ok(false);
        }

        if is_bund_input {
            if matches!(key.code, KeyCode::Enter) {
                let (typed, hook) = match &self.modal {
                    Modal::BundInput { input, hook, .. } => {
                        (input.as_str().to_string(), hook.clone())
                    }
                    _ => (String::new(), String::new()),
                };
                self.modal = Modal::None;
                if !hook.is_empty() {
                    crate::scripting::hooks::fire(
                        &hook,
                        vec![rust_dynamic::value::Value::from_string(typed)],
                    );
                }
                return Ok(false);
            }
            if let Modal::BundInput { input, .. } = &mut self.modal {
                handle_text_input_key(input, key);
            }
            return Ok(false);
        }

        if is_chat_search_prompt {
            // Enter commits the query into `chat_search`; the rendered
            // chat-history pane then auto-centres on the match. Esc
            // closes the modal without starting a search (global Esc
            // handler at the top of this function does the close).
            if matches!(key.code, KeyCode::Enter) {
                let query = match &self.modal {
                    Modal::ChatSearchPrompt { input } => {
                        input.as_str().trim().to_string()
                    }
                    _ => String::new(),
                };
                self.modal = Modal::None;
                self.commit_chat_search(query);
                return Ok(false);
            }
            if let Modal::ChatSearchPrompt { input } = &mut self.modal {
                handle_text_input_key(input, key);
            }
            return Ok(false);
        }

        if is_find {
            let mut commit = false;
            if let Modal::FindReplace {
                search_input,
                replace_input,
                focus_replace,
                scope_book,
            } = &mut self.modal
            {
                match key.code {
                    KeyCode::Enter => commit = true,
                    // 1.2.22 R.3 — Ctrl+B toggles project (book) scope.
                    KeyCode::Char('b') | KeyCode::Char('B')
                        if key.modifiers.contains(KeyModifiers::CONTROL) =>
                    {
                        if replace_input.is_some() {
                            *scope_book = !*scope_book;
                        }
                    }
                    KeyCode::Tab => {
                        if replace_input.is_some() {
                            *focus_replace = !*focus_replace;
                        }
                    }
                    KeyCode::Backspace => {
                        if *focus_replace {
                            if let Some(r) = replace_input.as_mut() {
                                r.backspace();
                            }
                        } else {
                            search_input.backspace();
                        }
                    }
                    KeyCode::Delete => {
                        if *focus_replace {
                            if let Some(r) = replace_input.as_mut() {
                                r.delete();
                            }
                        } else {
                            search_input.delete();
                        }
                    }
                    KeyCode::Left => {
                        if *focus_replace {
                            if let Some(r) = replace_input.as_mut() {
                                r.move_left();
                            }
                        } else {
                            search_input.move_left();
                        }
                    }
                    KeyCode::Right => {
                        if *focus_replace {
                            if let Some(r) = replace_input.as_mut() {
                                r.move_right();
                            }
                        } else {
                            search_input.move_right();
                        }
                    }
                    KeyCode::Home => {
                        if *focus_replace {
                            if let Some(r) = replace_input.as_mut() {
                                r.move_home();
                            }
                        } else {
                            search_input.move_home();
                        }
                    }
                    KeyCode::End => {
                        if *focus_replace {
                            if let Some(r) = replace_input.as_mut() {
                                r.move_end();
                            }
                        } else {
                            search_input.move_end();
                        }
                    }
                    KeyCode::Char(c) => {
                        let mut residual = key.modifiers;
                        residual.remove(KeyModifiers::SHIFT);
                        if residual.is_empty() {
                            let final_c = if key.modifiers.contains(KeyModifiers::SHIFT)
                                && c.is_ascii_alphabetic()
                            {
                                c.to_ascii_uppercase()
                            } else {
                                c
                            };
                            if *focus_replace {
                                if let Some(r) = replace_input.as_mut() {
                                    r.insert_char(final_c);
                                }
                            } else {
                                search_input.insert_char(final_c);
                            }
                        }
                    }
                    _ => {}
                }
            }
            if commit {
                self.commit_find();
            }
            return Ok(false);
        }

        if is_replace_review {
            self.replace_review_handle_key(key);
            return Ok(false);
        }


        if is_file_picker {
            let mut commit = false;
            if let Modal::FilePicker(picker) = &mut self.modal {
                match key.code {
                    KeyCode::Up => picker.move_up(),
                    KeyCode::Down => picker.move_down(),
                    KeyCode::PageUp => picker.page_up(10),
                    KeyCode::PageDown => picker.page_down(10),
                    KeyCode::Home => picker.jump_first(),
                    KeyCode::End => picker.jump_last(),
                    KeyCode::Right => picker.expand(),
                    KeyCode::Left => picker.collapse_or_step_out(),
                    KeyCode::Enter => commit = true,
                    _ => {}
                }
            }
            if commit {
                self.commit_file_pick();
            }
            return Ok(false);
        }

        if is_renaming {
            let mut commit = false;
            if let Modal::Renaming { input, .. } = &mut self.modal {
                match key.code {
                    KeyCode::Enter => commit = true,
                    KeyCode::Backspace => input.backspace(),
                    KeyCode::Delete => input.delete(),
                    KeyCode::Left => input.move_left(),
                    KeyCode::Right => input.move_right(),
                    KeyCode::Home => input.move_home(),
                    KeyCode::End => input.move_end(),
                    KeyCode::Char(c) => {
                        let mut residual = key.modifiers;
                        residual.remove(KeyModifiers::SHIFT);
                        if residual.is_empty() {
                            let final_c = if key.modifiers.contains(KeyModifiers::SHIFT)
                                && c.is_ascii_alphabetic()
                            {
                                c.to_ascii_uppercase()
                            } else {
                                c
                            };
                            input.insert_char(final_c);
                        }
                    }
                    _ => {}
                }
            }
            if commit {
                self.commit_rename();
            }
            return Ok(false);
        }

        if is_snapshot {
            let mut commit = false;
            let mut delete = false;
            let mut view_diff = false;
            // 1.2.8+ — filter-focus mode: typed chars edit the
            // annotation filter, Backspace edits, Esc exits
            // filter focus.  Picker chords (Up/Down/Enter/D/V/
            // Delete) only fire when filter mode is OFF.
            if self.snapshot_filter_focused {
                match key.code {
                    KeyCode::Esc => {
                        // Exit filter mode but keep the query so
                        // the cursor stays on the narrowed list.
                        self.snapshot_filter_focused = false;
                        // Force the outer Esc handler to NOT close
                        // the modal.  We've already handled this key.
                        return Ok(false);
                    }
                    KeyCode::Enter => {
                        // Enter inside filter mode commits the
                        // filter (exits filter mode) but does not
                        // load the snapshot — second Enter does
                        // that.  Mirrors browser address-bar UX.
                        self.snapshot_filter_focused = false;
                    }
                    KeyCode::Backspace => {
                        self.snapshot_filter.pop();
                        // Reset cursor — visible list shrunk/grew.
                        if let Modal::SnapshotPicker { cursor, .. } = &mut self.modal {
                            *cursor = 0;
                        }
                    }
                    KeyCode::Char(c) => {
                        self.snapshot_filter.push(c);
                        if let Modal::SnapshotPicker { cursor, .. } = &mut self.modal {
                            *cursor = 0;
                        }
                    }
                    _ => {}
                }
                return Ok(false);
            }
            // `/` from chord mode enters filter focus.
            if matches!(key.code, KeyCode::Char('/')) {
                self.snapshot_filter_focused = true;
                return Ok(false);
            }
            // Compute visible length up-front so cursor clamps to
            // the FILTERED list, not the absolute snapshots Vec.
            let visible_len = match &self.modal {
                Modal::SnapshotPicker { snapshots, .. } => {
                    self.visible_snapshot_indices(snapshots).len()
                }
                _ => 0,
            };
            // 1.2.12+ Phase B follow-up — Shift+Enter
            // pins the focused snapshot to the split-
            // view secondary slot as a read-only
            // historical view of the paragraph.
            // Combined with Shift+F4, this is the
            // draft-vs-current comparison workflow the
            // split-view proposal promised.
            let mut pin_snapshot = false;
            if let Modal::SnapshotPicker { cursor, .. } = &mut self.modal {
                match key.code {
                    KeyCode::Up => {
                        if *cursor > 0 {
                            *cursor -= 1;
                        }
                    }
                    KeyCode::Down => {
                        if *cursor + 1 < visible_len {
                            *cursor += 1;
                        }
                    }
                    KeyCode::Home => *cursor = 0,
                    KeyCode::End => {
                        *cursor = visible_len.saturating_sub(1);
                    }
                    KeyCode::Enter => {
                        if key.modifiers.contains(KeyModifiers::SHIFT) {
                            pin_snapshot = true;
                        } else {
                            commit = true;
                        }
                    }
                    // D (case-insensitive) or the Delete key removes
                    // the cursor's snapshot. No further confirmation —
                    // snapshots are explicit creations (F5 / Ctrl+B N),
                    // and refreshing the list keeps the cursor sane.
                    KeyCode::Char('D') | KeyCode::Char('d') | KeyCode::Delete => {
                        delete = true;
                    }
                    // V (case-insensitive) opens the snapshot
                    // diff modal against the open paragraph's
                    // current buffer. Read-only — Esc returns to
                    // the picker; closing the picker entirely
                    // requires another Esc.
                    KeyCode::Char('V') | KeyCode::Char('v') => {
                        view_diff = true;
                    }
                    _ => {}
                }
            }
            if commit {
                self.commit_snapshot_load();
            } else if delete {
                self.delete_current_snapshot();
            } else if view_diff {
                self.open_snapshot_diff();
            } else if pin_snapshot {
                self.pin_snapshot_to_secondary();
            }
            return Ok(false);
        }

        if is_adding {
            let mut commit = false;
            if let Modal::Adding { input, .. } = &mut self.modal {
                match key.code {
                    KeyCode::Enter => commit = true,
                    KeyCode::Backspace => input.backspace(),
                    KeyCode::Delete => input.delete(),
                    KeyCode::Left => input.move_left(),
                    KeyCode::Right => input.move_right(),
                    KeyCode::Home => input.move_home(),
                    KeyCode::End => input.move_end(),
                    KeyCode::Char(c) => {
                        let mut residual = key.modifiers;
                        residual.remove(KeyModifiers::SHIFT);
                        if residual.is_empty() {
                            // Some terminals report Shift+letter as lowercase
                            // char + SHIFT modifier; others as uppercase char
                            // + SHIFT. Normalize so capital letters always go
                            // into the buffer when Shift was held.
                            let final_c = if key.modifiers.contains(KeyModifiers::SHIFT)
                                && c.is_ascii_alphabetic()
                            {
                                c.to_ascii_uppercase()
                            } else {
                                c
                            };
                            input.insert_char(final_c);
                        }
                    }
                    _ => {}
                }
            }
            if commit {
                self.commit_add();
            }
        } else if is_deleting {
            match key.code {
                KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => self.commit_delete(),
                KeyCode::Char('n') | KeyCode::Char('N') => self.modal = Modal::None,
                _ => {}
            }
        }
        Ok(false)
    }

    fn commit_add(&mut self) {
        let (kind, parent_id, raw_title, position) = match &self.modal {
            Modal::Adding {
                kind,
                parent_id,
                input,
                position,
                ..
            } => (
                *kind,
                *parent_id,
                input.as_str().trim().to_string(),
                *position,
            ),
            _ => return,
        };

        // Paragraphs can be added with an empty title — they'll be given a
        // placeholder ("Untitled paragraph") that the next save replaces with
        // the first sentence of the body. Branches still require a title
        // because they have no content from which to derive one.
        let title = if raw_title.is_empty() {
            if kind == NodeKind::Paragraph {
                PARAGRAPH_PLACEHOLDER_TITLE.to_string()
            } else {
                self.status =
                    format!("a {} needs a title — type one or Esc to cancel", kind.as_str());
                return;
            }
        } else {
            raw_title
        };

        let parent = parent_id.and_then(|id| self.hierarchy.get(id)).cloned();

        // 1.2.13+ Phase D.1 hotfix — Dictionary entries
        // land under an alphabet-bucket subchapter, not
        // directly under the Dictionary chapter the user
        // pointed at.  Intercept Paragraph adds whose
        // parent ancestry includes a `Language/<X>/Dictionary`
        // chapter and route through
        // `add_dictionary_entry_impl` (which handles the
        // bucket derivation + duplicate check + body
        // seeding the same way `inkhaven language
        // add-word` does).
        if kind == crate::store::NodeKind::Paragraph {
            if let Some(lang_book) = self.find_language_book_for_dictionary_paragraph(parent.as_ref()) {
                self.status = format!(
                    "adding `{}` to {}/Dictionary...",
                    title, lang_book.title
                );
                match crate::cli::language::add_dictionary_entry_impl(
                    &self.store,
                    &self.cfg,
                    &lang_book,
                    &title,
                    "",
                    "",
                    None,
                ) {
                    Ok((entry, bucket)) => {
                        let new_id = entry.id;
                        self.status = format!(
                            "added `{}` to {}/Dictionary/{} — open the paragraph to fill POS / translation",
                            title, lang_book.title, bucket
                        );
                        self.modal = Modal::None;
                        self.reload_hierarchy();
                        if let Some(i) =
                            self.rows.iter().position(|(id, _)| *id == new_id)
                        {
                            self.tree_cursor = i;
                        }
                    }
                    Err(e) => {
                        self.status = format!("add word failed: {e}");
                    }
                }
                return;
            }
        }

        // 1.2.13+ Phase D.1 hotfix — Grammar / Phonology
        // paragraphs created via the tree-pane Add
        // Paragraph chord get the schema-aware HJSON
        // template seeded into their body so authors
        // don't have to remember the field names.  Falls
        // through to the normal create_node flow; the
        // body update fires in the Ok branch below.
        // 1.2.14+ Phase A.1 — paragraphs created under
        // the Threads system book get a fully-seeded
        // thread HJSON template too (same pattern as
        // Language Grammar/Phonology).  Title comes
        // from the typed name; status defaults to
        // `setup`, weight to `major`.  The
        // Language-rule detector takes precedence
        // because the Language subtree carries its own
        // chapter-specific templates.
        let seed_body_after_create: Option<String> =
            if kind == crate::store::NodeKind::Paragraph {
                self.language_rule_chapter_for_paragraph(parent.as_ref())
                    .map(|s| s.to_string())
                    .or_else(|| {
                        if self.parent_is_under_threads(parent.as_ref()) {
                            Some(crate::cli::thread::seed_thread_body_for_tui(
                                &title,
                            ))
                        } else {
                            None
                        }
                    })
            } else {
                None
            };

        match self.store.create_node(
            &self.cfg,
            &self.hierarchy,
            kind,
            &title,
            parent.as_ref(),
            None,
            position,
        ) {
            Ok(mut node) => {
                let new_id = node.id;
                // Seed body if a rule-chapter template
                // applies.  Also flip content_type to
                // `"hjson"` so the editor renders with
                // HJSON syntax highlighting (matches the
                // Meta/overview + dictionary-entry
                // paragraphs).  Writes go to disk FIRST
                // (the editor reads the .typ file off
                // disk; `update_paragraph_content` only
                // touches bdslib), then bdslib.  Errors
                // are swallowed — the node is created
                // either way; the author can re-edit if
                // the seed fails.
                if let Some(body) = seed_body_after_create.as_deref() {
                    node.content_type = Some("hjson".to_string());
                    if let Some(rel) = &node.file {
                        let abs = self.layout.root.join(rel);
                        let _ = std::fs::write(&abs, body.as_bytes());
                    }
                    let _ = self
                        .store
                        .update_paragraph_content(&mut node, body.as_bytes());
                }
                // 1.2.13+ Phase D.1 — when the new book lands
                // directly under the `Language` system book the
                // tree-pane Add Book chord (`A` in tree, `+` etc.)
                // should produce a fully-scaffolded language
                // sub-book matching what `inkhaven language init`
                // emits, not an empty book the author would then
                // have to hand-populate with the 5 standard
                // chapters.  Detected by parent's `system_tag`.
                let parent_is_language =
                    kind == crate::store::NodeKind::Book
                        && parent.as_ref().and_then(|p| p.system_tag.as_deref())
                            == Some(crate::store::SYSTEM_TAG_LANGUAGES);
                if parent_is_language {
                    // Scaffold makes 6 sequential
                    // create_node calls + hierarchy
                    // reloads — on a large project this
                    // can block the render loop for
                    // several seconds.  Set an
                    // intermediate status (it'll only
                    // become visible after the scaffold
                    // returns, but at least the next
                    // frame shows the intent if the user
                    // glances mid-operation; better than
                    // leaving the previous "new
                    // language..." prompt up while the
                    // scaffold churns) so the user sees
                    // progress rather than silence.
                    self.status =
                        format!("scaffolding language `{}` (this may take a moment)...",
                            node.title);
                    match crate::cli::language::scaffold_language_chapters(
                        &self.store,
                        &self.cfg,
                        &node,
                        |_| {},
                    ) {
                        Ok(()) => {
                            self.status = format!(
                                "added language `{}` — 5 chapters scaffolded; \
                                 edit Meta/overview to set the alphabet",
                                node.title
                            );
                        }
                        Err(e) => {
                            // The book itself was created; the
                            // scaffold partial-failed.  Surface the
                            // error but don't roll back — the user
                            // can re-run init OR hand-populate the
                            // remaining chapters.
                            self.status = format!(
                                "added language `{}` — but scaffold partial: {e}",
                                node.title
                            );
                        }
                    }
                } else if seed_body_after_create.is_some() {
                    // Grammar / Phonology rule paragraph
                    // with the schema-aware template
                    // seeded.  Confirm in the status
                    // bar so the author knows the body
                    // already has the right fields.
                    self.status = format!(
                        "added {} `{}` — HJSON template seeded; open the paragraph to fill the fields",
                        kind.as_str(),
                        node.title
                    );
                } else {
                    // For root-level user books: provision the artefacts
                    // subdirectory and the Typst-book skeleton (chapter +
                    // index/settings/globals paragraphs). No-op for other
                    // kinds; failure logs to status but doesn't roll back
                    // the just-created book.
                    if let Err(e) = self.store.provision_user_book(&self.cfg, &node) {
                        self.status = format!(
                            "added {} `{}` — but Typst skeleton failed: {e}",
                            kind.as_str(),
                            node.title
                        );
                    } else {
                        self.status = format!("added {} `{}`", kind.as_str(), node.title);
                    }
                }
                self.modal = Modal::None;
                self.reload_hierarchy();
                if let Some(i) = self.rows.iter().position(|(id, _)| *id == new_id) {
                    self.tree_cursor = i;
                }
            }
            Err(e) => {
                self.status = format!("add failed: {e}");
            }
        }
    }

    fn commit_delete(&mut self) {
        let (root_id, root_kind, ids, title) = match &self.modal {
            Modal::Deleting {
                root_id,
                root_kind,
                ids,
                title,
                ..
            } => (*root_id, *root_kind, ids.clone(), title.clone()),
            _ => return,
        };
        let root_node = match self.hierarchy.get(root_id) {
            Some(n) => n.clone(),
            None => {
                self.modal = Modal::None;
                self.status = "node already gone".into();
                return;
            }
        };
        let parent_id = root_node.parent_id;

        let fs_rel = match root_kind {
            NodeKind::Paragraph => root_node
                .file
                .as_ref()
                .map(std::path::PathBuf::from)
                .unwrap_or_default(),
            _ => self.hierarchy.fs_path(&root_node, &self.layout),
        };

        // 1.2.7+ — stash a single-paragraph delete into the
        // kill-ring so Ctrl+B U can recover the content
        // afterwards. Skipped for branch deletes (chapters /
        // books) because subtree restoration without UUID
        // preservation is too risky to ship without store
        // API support.
        if root_kind == NodeKind::Paragraph && ids.len() == 1 {
            self.stash_deleted_paragraph(&root_node);
        }
        // 1.2.8+: branch deletes no longer clear the
        // kill-ring. Older single-¶ entries in the ring
        // are still valid recoveries — they reference paths
        // that may or may not exist after the branch went,
        // but the restore flow handles "parent gone" by
        // falling back to InsertPosition::End. Keeping the
        // entries is strictly more useful than dropping them.

        if let Err(e) = self.store.delete_subtree(&fs_rel, &ids) {
            self.status = format!("delete failed: {e}");
            // The push-front above already happened for a
            // single-¶ stash; drop it again since the delete
            // didn't actually fire — the on-disk paragraph
            // still exists and the front-stash would create
            // a duplicate on the next Ctrl+B U.
            if root_kind == NodeKind::Paragraph && ids.len() == 1 {
                self.kill_ring.pop_front();
            }
            return;
        }

        // Close editor if its open doc was inside the deleted subtree.
        if let Some(doc) = &self.opened {
            if ids.contains(&doc.id) {
                self.opened = None;
            }
        }

        self.modal = Modal::None;
        let ring_len = self.kill_ring.len();
        let undo_hint = if root_kind == NodeKind::Paragraph
            && ids.len() == 1
            && ring_len > 0
        {
            if ring_len == 1 {
                " · Ctrl+B U to restore (new uuid — paragraph links to old id stay broken)".to_string()
            } else {
                format!(
                    " · Ctrl+B U restore · Ctrl+V Shift+U picker ({ring_len} in ring)",
                )
            }
        } else {
            String::new()
        };
        self.status = format!(
            "deleted {} `{}` ({} other node{} removed){undo_hint}",
            root_kind.as_str(),
            title,
            ids.len() - 1,
            if ids.len() == 2 { "" } else { "s" }
        );
        self.reload_hierarchy();
        if let Some(pid) = parent_id {
            if let Some(i) = self.rows.iter().position(|(id, _)| *id == pid) {
                self.tree_cursor = i;
            }
        }
    }

    // -------- open / save -------------------------------------------------

    fn open_selected(&mut self) -> Result<()> {
        let Some(&(id, _)) = self.rows.get(self.tree_cursor) else {
            return Ok(());
        };
        let Some(node) = self.hierarchy.get(id).cloned() else {
            return Ok(());
        };

        match node.kind {
            // Scripts are text leaves like Paragraphs — same load
            // path, same editor surface. Real Bund syntax
            // highlighting is a follow-up; today they render as
            // plain text (which is still legible because bundcore's
            // syntax is sparse: words + braces + strings).
            NodeKind::Paragraph | NodeKind::Script => self.load_paragraph(&node)?,
            NodeKind::Image => self.show_image_info(&node),
            _ => {
                self.status = format!(
                    "`{}` is a {} (Enter opens paragraphs / images / scripts)",
                    node.title,
                    node.kind.as_str()
                );
            }
        }
        Ok(())
    }

    /// Enter on an Image row: try the ratatui-image preview modal,
    /// fall back to a status-bar info line when the picker isn't
    /// available (preview disabled, terminal lacks graphics protocol,
    /// or the image bytes aren't decodable by the `image` crate).
    fn show_image_info(&mut self, node: &Node) {
        let fs_rel = node.file.clone().unwrap_or_else(|| "<no path>".into());
        let abs = self.layout.root.join(&fs_rel);
        let size = std::fs::metadata(&abs)
            .map(|m| m.len())
            .unwrap_or(0);

        // Preview path: fetch bytes from bdslib (source of truth),
        // decode, build a resize protocol, pop the modal.
        if let Some(picker) = self.image_picker.as_ref() {
            match self.store.image_bytes(node.id) {
                Ok(Some(bytes)) => match image::load_from_memory(&bytes) {
                    Ok(dyn_img) => {
                        let proto = picker.new_resize_protocol(dyn_img);
                        self.modal = Modal::ImagePreview {
                            title: node.title.clone(),
                            fs_rel: fs_rel.clone(),
                            size_bytes: size,
                            proto,
                        };
                        self.status = format!(
                            "🖼 {}  ·  Esc closes  ·  {} bytes",
                            node.title, size
                        );
                        return;
                    }
                    Err(e) => {
                        tracing::warn!(
                            "image decode failed for {}: {e} — falling back to info line",
                            node.title
                        );
                    }
                },
                Ok(None) => {
                    tracing::warn!(
                        "image {} has no bytes in bdslib — info line only",
                        node.title
                    );
                }
                Err(e) => {
                    tracing::warn!("image_bytes({}): {e}", node.title);
                }
            }
        }

        // Fallback: status-bar one-liner.
        let caption_hint = node
            .image_caption
            .as_deref()
            .filter(|s| !s.is_empty())
            .unwrap_or("(no caption)");
        self.status = format!(
            "🖼 {} · {} · {} bytes · {}",
            node.title, fs_rel, size, caption_hint
        );
    }

    /// 1.2.5+: re-parse the open paragraph with `typst-syntax` and
    /// cache the resulting diagnostics on `OpenedDoc`. Honors the
    /// `typst_compile.diagnostics` HJSON flag and the buffer's
    /// `content_type` — only typst sources are checked (Bund /
    /// HJSON / others skip out cleanly). Status bar surfaces the
    /// first error so the user sees the line number at a glance;
    /// the rest stay cached on the doc for any future "next error"
    /// chord.
    fn refresh_typst_diagnostics_for_opened(&mut self) {
        if !self.cfg.typst_compile.diagnostics {
            return;
        }
        let Some(doc) = self.opened.as_mut() else {
            return;
        };
        // Anything not typst-shaped — Bund scripts, HJSON data
        // nodes, images — should never be fed to the typst
        // parser; just clear stale diagnostics and bail.
        let is_typst = match doc.content_type.as_deref() {
            None | Some("") | Some("typst") => true,
            _ => false,
        };
        if !is_typst {
            doc.typst_diagnostics.clear();
            doc.typst_diagnostics_checked_at = std::time::Instant::now();
            return;
        }
        let body = doc.textarea.lines().join("\n");
        // Phase 1 baseline: parse-only diagnostics via `typst-syntax`.
        // Cheap, always available, no engine dependency.
        let mut diags = crate::typst_check::check(&body);
        // 1.2.5+: when the user has the in-process engine on AND
        // opted into semantic diagnostics, run a full
        // `typst::compile` against the paragraph in isolation and
        // surface semantic errors the parser can't catch
        // (unknown functions, type errors, etc.). We APPEND to
        // the parse diagnostics rather than replace — a
        // syntactically-broken buffer often produces a flurry of
        // confusing semantic errors and the parse error is the
        // root cause to surface first.
        if self.cfg.typst_compile.semantic_diagnostics
            && self.cfg.typst_compile.use_inprocess_engine()
            && diags.is_empty()
        {
            let settings = crate::typst_world::WorldSettings::from_cfg(
                &self.cfg.typst_compile,
            );
            let semantic =
                crate::typst_inprocess::check_semantic(&body, settings);
            diags.extend(semantic);
        }
        doc.typst_diagnostics = diags;
        doc.typst_diagnostics_checked_at = std::time::Instant::now();
        if let Some(first) = doc.typst_diagnostics.first() {
            // Don't blow away a more-recent status (a save's own
            // "wrote N bytes" message etc.) — only stamp the
            // diagnostics line if we have errors to show. The
            // save-path caller is OK with this being the final
            // status because errors-on-save are exactly what the
            // user needs to see next.
            self.status = first.summary();
        }

        // 1.2.6+ — fire `hook.on_diagnostic` when the diagnostic
        // state changes. Snapshot is `(count, first-message)`;
        // we re-fire on clean→errored, count change, or top-
        // message change. Avoids spamming hooks on every idle
        // tick when nothing actually moved.
        let snapshot = (
            doc.typst_diagnostics.len(),
            doc.typst_diagnostics
                .first()
                .map(|d| d.message.clone())
                .unwrap_or_default(),
        );
        let changed = doc.typst_diag_last_fired.as_ref() != Some(&snapshot);
        if changed {
            doc.typst_diag_last_fired = Some(snapshot.clone());
            let paragraph_id = doc.id;
            // hook.on_diagnostic ( uuid count first-message -- )
            crate::scripting::hooks::fire(
                "hook.on_diagnostic",
                vec![
                    rust_dynamic::value::Value::from_string(
                        paragraph_id.to_string(),
                    ),
                    rust_dynamic::value::Value::from_int(snapshot.0 as i64),
                    rust_dynamic::value::Value::from_string(snapshot.1.clone()),
                ],
            );
        }
    }

    /// Create an event paragraph under the book's Timeline
    /// chapter from the swim-lane "n" path. Returns the new
    /// node id; status messaging is the caller's job.
    fn create_event_at_cursor(
        &mut self,
        book_id: Uuid,
        title: &str,
        cursor_ticks: i64,
        track: Option<&str>,
    ) -> std::result::Result<(), String> {
        let timeline_chapter_id = self
            .store
            .ensure_timeline_chapter(&self.cfg, book_id)
            .map_err(|e| format!("{e}"))?;
        self.reload_hierarchy();
        let timeline_chapter = self
            .hierarchy
            .get(timeline_chapter_id)
            .cloned()
            .ok_or_else(|| "Timeline chapter vanished after creation".to_string())?;
        let mut node = self
            .store
            .create_node(
                &self.cfg,
                &self.hierarchy,
                NodeKind::Paragraph,
                title,
                Some(&timeline_chapter),
                None,
                InsertPosition::End,
            )
            .map_err(|e| format!("create_node: {e}"))?;
        node.event = Some(crate::store::node::EventData {
            start_ticks: cursor_ticks,
            end_ticks: None,
            precision: crate::timeline::Precision::Day,
            characters: Vec::new(),
            places: Vec::new(),
            track: track.map(str::to_owned),
        });
        crate::store::reconcile_event_orphan_tag(&mut node);
        node.modified_at = chrono::Utc::now();
        self.store
            .raw()
            .update_metadata(node.id, node.to_json())
            .map_err(|e| format!("update_metadata: {e}"))?;
        self.store.sync().map_err(|e| format!("sync: {e}"))?;
        // 1.2.6+ — same hook the CLI / Bund paths fire.
        crate::scripting::hooks::fire(
            "hook.on_event_added",
            vec![rust_dynamic::value::Value::from_string(
                node.id.to_string(),
            )],
        );
        self.reload_hierarchy();
        Ok(())
    }

    /// `n` — pop a small one-line input for the new event's
    /// title; on Enter the event is created at
    /// `cursor_ticks` with the current track highlight (or
    /// the default track).
    /// `Ctrl+V Shift+I` — pop a one-line edit prompt for the
    /// open event paragraph's start / end / track. Pipe-
    /// separated:
    ///   `Sol 13 | Sol 14 | main`     ← start, end, track
    ///   `Sol 13 |  | main`           ← no end
    ///   `Sol 13 | Sol 14 |`          ← drop the track
    /// Pre-fills from current values. Precision is re-derived
    /// from the start string on commit. No-op when the open
    /// paragraph isn't an event.
    fn open_edit_event_metadata_prompt(&mut self) {
        let Some(doc) = self.opened.as_ref() else {
            self.status =
                "edit event: no paragraph open (Ctrl+V Shift+I needs an editor buffer)".into();
            return;
        };
        let event_id = doc.id;
        let node = match self.hierarchy.get(event_id).cloned() {
            Some(n) => n,
            None => {
                self.status = "edit event: paragraph missing from hierarchy".into();
                return;
            }
        };
        let ev = match node.event.as_ref() {
            Some(ev) => ev,
            None => {
                self.status =
                    "edit event: `{open paragraph}` isn't an event — use `inkhaven event add` first".into();
                return;
            }
        };
        let cal = crate::timeline::Calendar::from_config(
            self.cfg.timeline.calendar.clone(),
        );
        let start_str = cal.format(
            crate::timeline::TimelinePoint::from_ticks(ev.start_ticks),
            ev.precision,
        );
        let end_str = ev
            .end_ticks
            .map(|t| {
                cal.format(
                    crate::timeline::TimelinePoint::from_ticks(t),
                    ev.precision,
                )
            })
            .unwrap_or_default();
        let track_str = ev.track.as_deref().unwrap_or("");
        let prefill = format!("{start_str} | {end_str} | {track_str}");
        let mut input = TextInput::new();
        for c in prefill.chars() {
            input.insert_char(c);
        }
        self.modal = Modal::TimelineEditEventPrompt { input, event_id };
        self.status =
            "edit event: <start> | <end> | <track> · empty middle = no end · Enter commits · Esc cancels".into();
    }

    fn commit_edit_event_metadata(&mut self) {
        let taken = std::mem::replace(&mut self.modal, Modal::None);
        let Modal::TimelineEditEventPrompt { input, event_id } = taken else {
            return;
        };
        let raw = input.as_str().to_owned();
        let parts: Vec<&str> = raw.split('|').collect();
        // Tolerate fewer than 3 segments (user may have left the
        // trailing pipes off — treat missing as empty).
        let start_str = parts.first().map(|s| s.trim()).unwrap_or("").to_owned();
        let end_str = parts.get(1).map(|s| s.trim()).unwrap_or("").to_owned();
        let track_str = parts.get(2).map(|s| s.trim()).unwrap_or("").to_owned();
        if start_str.is_empty() {
            self.status = "edit event: start can't be empty".into();
            return;
        }
        let cal = crate::timeline::Calendar::from_config(
            self.cfg.timeline.calendar.clone(),
        );
        let (start_point, precision) = match cal.parse(&start_str) {
            Ok(pp) => pp,
            Err(e) => {
                self.status = format!("edit event: bad start `{start_str}`: {e}");
                return;
            }
        };
        let end_ticks: Option<i64> = if end_str.is_empty() {
            None
        } else {
            match cal.parse(&end_str) {
                Ok((p, _)) => Some(p.ticks()),
                Err(e) => {
                    self.status = format!("edit event: bad end `{end_str}`: {e}");
                    return;
                }
            }
        };
        let new_track: Option<String> = if track_str.is_empty() {
            None
        } else {
            Some(track_str)
        };
        let mut node = match self.hierarchy.get(event_id).cloned() {
            Some(n) => n,
            None => {
                self.status = "edit event: paragraph vanished".into();
                return;
            }
        };
        let Some(ev) = node.event.as_mut() else {
            self.status = "edit event: paragraph isn't an event".into();
            return;
        };
        ev.start_ticks = start_point.ticks();
        ev.end_ticks = end_ticks;
        ev.precision = precision;
        ev.track = new_track;
        node.modified_at = chrono::Utc::now();
        crate::store::reconcile_event_orphan_tag(&mut node);
        if let Err(e) = self
            .store
            .raw()
            .update_metadata(node.id, node.to_json())
        {
            self.status = format!("edit event: persist: {e}");
            return;
        }
        if let Err(e) = self.store.sync() {
            self.status = format!("edit event: sync: {e}");
            return;
        }
        self.reload_hierarchy();
        self.status = format!(
            "event updated · start={start_str}{}",
            if !end_str.is_empty() {
                format!(" · end={end_str}")
            } else {
                String::new()
            }
        );
    }

    // ── 1.2.6+ scope navigation ──────────────────────────

    /// Ctrl+V t (1.2.6+) — open the swim-lane timeline view.
    /// Anchors to the current paragraph's nearest
    /// Subchapter / Chapter / Book ancestor; falls back to
    /// the tree cursor and finally to the first user book.
    fn open_timeline_view(&mut self) {
        if !self.cfg.timeline.enabled {
            self.status =
                "timeline view: timeline.enabled is false in HJSON — enable it to use Ctrl+V Shift+T".into();
            return;
        }
        let Some(book_id) = self.resolve_anchor_book() else {
            self.status =
                "timeline view: no user books in this project".into();
            return;
        };
        let scope_id = self.resolve_anchor_scope(book_id);
        let events = self.collect_book_events(book_id, false);
        let is_empty = events.is_empty();
        // 1.2.6+: when the book has no events yet, still open the
        // timeline at the epoch tick so the user can press `n` to
        // add the first event from inside the TUI. The previous
        // behaviour was to refuse-and-redirect to the CLI, which
        // hid the in-TUI add chord entirely.
        //
        // 1.2.6+ auto-fit: when events ARE present, compute the
        // total span (earliest start → latest end/start) and pick
        // a `ticks_per_cell` that makes the whole range fit in
        // the visible pane. The user then drills in with `+` /
        // `-`. Width is sampled via `crossterm::terminal::size()`
        // at open time — close enough; the swim-lane content
        // area is ~ `terminal_width - track_gutter - borders`.
        let (cursor_ticks, scroll_ticks, ticks_per_cell) = if events.is_empty() {
            (0i64, -20i64, 1.0f64)
        } else {
            timeline_auto_fit(&events)
        };
        let state = TimelineViewState {
            book_id,
            scope_id,
            nav_history: Vec::new(),
            events,
            track_highlight: None,
            ticks_per_cell,
            scroll_ticks,
            cursor_ticks,
            selected_event_id: None,
            collapsed_tracks: std::collections::HashSet::new(),
            expanded_track: None,
            focus_level: TimelineFocusLevel::Track,
            project_overlay: false,
            descent: None,
        };
        let crumb = self.timeline_scope_crumb(&state);
        self.modal = Modal::TimelineView { state };
        // 1.2.7+ — apply any cached per-book view state
        // (collapsed tracks, expanded track, zoom, scroll).
        // No-op on a fresh book or a session.json without an
        // entry — auto-fit defaults from above stay.
        self.timeline_restore_view_state();
        self.status = if is_empty {
            format!("timeline {crumb} · empty — press `n` to add the first event · Esc closes")
        } else {
            format!(
                "timeline {crumb} · auto-fit · ↑↓ event step · ←→ scroll · +/- zoom · n new · Esc closes"
            )
        };
    }

    /// 1.2.6+ — `Ctrl+V Shift+E`. Opens the timeline view and
    /// immediately triggers the new-event prompt so a fresh
    /// project (zero events) can add its first event from any
    /// pane without going through the CLI. When the timeline
    /// has events, the prompt fires at the timeline cursor's
    /// current tick (same as pressing `n` after opening).
    fn open_new_event_prompt_from_anywhere(&mut self) {
        // 1.2.7+ — pre-fill the event title with the editor's
        // current selection, if any. Lets the user highlight
        // a phrase like "the storm at dawn" → Ctrl+V Shift+E
        // → modal pops with that text already in the input.
        // Selection truncated to 60 chars (the prompt's
        // practical width); newlines flattened to spaces.
        let prefill = self.editor_selection_text().map(|s| {
            let flat = s
                .split_whitespace()
                .collect::<Vec<_>>()
                .join(" ");
            if flat.chars().count() > 60 {
                let truncated: String = flat.chars().take(59).collect();
                format!("{truncated}…")
            } else {
                flat
            }
        });
        self.open_timeline_view();
        // open_timeline_view bails before setting Modal::TimelineView
        // when timeline is disabled / no books exist; in that case
        // the status bar already explains why, and the chained
        // prompt is a no-op.
        if matches!(self.modal, Modal::TimelineView { .. }) {
            self.timeline_open_new_event_prompt();
            if let (Some(text), Modal::TimelineNewEventPrompt { input, .. }) =
                (prefill, &mut self.modal)
            {
                for c in text.chars() {
                    input.insert_char(c);
                }
                self.status =
                    "new event: title pre-filled from editor selection · Enter commits · Esc cancels".into();
            }
        }
    }

    /// Walk up from the editor (or tree cursor) to find the
    /// containing user Book. Returns its UUID. None when the
    /// project has no user books at all.
    fn resolve_anchor_book(&self) -> Option<Uuid> {
        // 1.2.6+ — focus-aware: when the Tree pane has focus,
        // the tree cursor wins over the (possibly stale) open
        // paragraph. Previously the open-paragraph branch
        // always won, so navigating the tree cursor into a
        // different book and pressing Ctrl+V Shift+T still
        // opened the timeline of the book the editor was last
        // viewing. Editor / AI / search focus still prefer the
        // open paragraph (the natural "I'm editing X, timeline
        // for X's book" reading).
        let candidate_id = if self.focus == Focus::Tree {
            self.rows
                .get(self.tree_cursor)
                .map(|(id, _)| *id)
                .or_else(|| self.opened.as_ref().map(|d| d.id))?
        } else {
            self.opened
                .as_ref()
                .map(|d| d.id)
                .or_else(|| self.rows.get(self.tree_cursor).map(|(id, _)| *id))?
        };
        let mut cur_id = candidate_id;
        loop {
            let Some(node) = self.hierarchy.get(cur_id) else {
                break;
            };
            if node.kind == NodeKind::Book && node.system_tag.is_none() {
                return Some(node.id);
            }
            match node.parent_id {
                Some(p) => cur_id = p,
                None => break,
            }
        }
        // Fallback: any user book.
        self.hierarchy
            .children_of(None)
            .into_iter()
            .find(|n| n.kind == NodeKind::Book && n.system_tag.is_none())
            .map(|n| n.id)
    }

    /// Default scope = current paragraph's nearest Subchapter
    /// (or Chapter, or the book itself). Walks the parent
    /// chain; never returns a non-tree-cursor scope.
    fn resolve_anchor_scope(&self, book_id: Uuid) -> Uuid {
        // Same focus-aware preference as resolve_anchor_book —
        // Tree-pane focus uses the tree cursor; other panes use
        // the open paragraph. Without this the scope walked up
        // from a stale opened-doc paragraph and landed in the
        // wrong book entirely.
        let candidate = if self.focus == Focus::Tree {
            self.rows
                .get(self.tree_cursor)
                .map(|(id, _)| *id)
                .or_else(|| self.opened.as_ref().map(|d| d.id))
        } else {
            self.opened
                .as_ref()
                .map(|d| d.id)
                .or_else(|| self.rows.get(self.tree_cursor).map(|(id, _)| *id))
        };
        let Some(mut cur_id) = candidate else {
            return book_id;
        };
        loop {
            let Some(node) = self.hierarchy.get(cur_id) else {
                return book_id;
            };
            match node.kind {
                NodeKind::Subchapter | NodeKind::Chapter | NodeKind::Book => return node.id,
                _ => {}
            }
            match node.parent_id {
                Some(p) => cur_id = p,
                None => return book_id,
            }
        }
    }

    /// Snapshot every event under `book_id` (or every user
    /// book when `project = true`) into `TimelineEvent`s. The
    /// returned list is sorted by start_ticks.
    fn collect_book_events(&self, book_id: Uuid, project: bool) -> Vec<TimelineEvent> {
        let book_slugs: std::collections::HashMap<Uuid, String> = self
            .hierarchy
            .children_of(None)
            .into_iter()
            .filter(|n| n.kind == NodeKind::Book && n.system_tag.is_none())
            .map(|n| (n.id, n.slug.clone()))
            .collect();
        let target_books: Vec<Uuid> = if project {
            book_slugs.keys().copied().collect()
        } else {
            vec![book_id]
        };
        let mut out: Vec<TimelineEvent> = Vec::new();
        for (n, _) in self.hierarchy.flatten() {
            let Some(ev) = n.event.as_ref() else { continue };
            // Walk up to find the containing user book.
            let mut cur = n.parent_id;
            let mut book_for_node: Option<Uuid> = None;
            while let Some(pid) = cur {
                match self.hierarchy.get(pid) {
                    Some(p) => {
                        if p.kind == NodeKind::Book && p.system_tag.is_none() {
                            book_for_node = Some(p.id);
                            break;
                        }
                        cur = p.parent_id;
                    }
                    None => break,
                }
            }
            let Some(book) = book_for_node else { continue };
            if !target_books.contains(&book) {
                continue;
            }
            let book_prefix = if project {
                book_slugs.get(&book).cloned().unwrap_or_default()
            } else {
                String::new()
            };
            out.push(TimelineEvent {
                id: n.id,
                title: n.title.clone(),
                start_ticks: ev.start_ticks,
                end_ticks: ev.end_ticks,
                precision: ev.precision,
                track: ev.track.clone(),
                is_orphan: n.tags.iter().any(|t| t.eq_ignore_ascii_case("orphan")),
                linked_paragraphs: n.linked_paragraphs.clone(),
                characters: ev.characters.clone(),
                places: ev.places.clone(),
                book_prefix,
            });
        }
        out.sort_by_key(|e| e.start_ticks);
        out
    }

    /// Ctrl+V e (1.2.6+) — gather every event in the project
    /// and pop the picker. Bails early when timeline is
    /// disabled in HJSON so users see a precise hint instead
    /// of an empty picker.
    fn open_event_picker(&mut self) {
        if !self.cfg.timeline.enabled {
            self.status =
                "event picker: timeline.enabled is false in HJSON — enable it to use Ctrl+V e".into();
            return;
        }
        let calendar = crate::timeline::Calendar::from_config(
            self.cfg.timeline.calendar.clone(),
        );
        let mut entries: Vec<EventPickerEntry> = self
            .hierarchy
            .flatten()
            .into_iter()
            .filter_map(|(n, _)| {
                let ev = n.event.as_ref()?;
                let start_str = calendar.format(
                    crate::timeline::TimelinePoint::from_ticks(ev.start_ticks),
                    ev.precision,
                );
                let glyph = if ev.end_ticks.is_some() {
                    "─"
                } else if n.tags.iter().any(|t| t.eq_ignore_ascii_case("orphan")) {
                    "◌"
                } else {
                    "●"
                };
                Some(EventPickerEntry {
                    id: n.id,
                    title: n.title.clone(),
                    start_ticks: ev.start_ticks,
                    start_str,
                    glyph: glyph.to_owned(),
                    track: ev.track.clone(),
                    is_orphan: n.tags.iter().any(|t| t.eq_ignore_ascii_case("orphan")),
                })
            })
            .collect();
        if entries.is_empty() {
            self.status =
                "event picker: no events yet — `inkhaven event add …` from the CLI".into();
            return;
        }
        entries.sort_by_key(|e| e.start_ticks);
        let total = entries.len();
        self.modal = Modal::EventPicker {
            entries,
            cursor: 0,
            track_filter: None,
        };
        self.status = format!(
            "events ({total}) · ↑↓ select · Enter opens · t cycles tracks · Esc closes"
        );
    }

    fn event_picker_handle_key(&mut self, key: KeyEvent) {
        let total = match &self.modal {
            Modal::EventPicker { entries, track_filter, .. } => {
                visible_event_entries(entries, track_filter.as_deref()).len()
            }
            _ => 0,
        };
        match key.code {
            KeyCode::Up => {
                if let Modal::EventPicker { cursor, .. } = &mut self.modal {
                    if *cursor > 0 {
                        *cursor -= 1;
                    }
                }
            }
            KeyCode::Down => {
                if let Modal::EventPicker { cursor, .. } = &mut self.modal {
                    if *cursor + 1 < total {
                        *cursor += 1;
                    }
                }
            }
            KeyCode::Home => {
                if let Modal::EventPicker { cursor, .. } = &mut self.modal {
                    *cursor = 0;
                }
            }
            KeyCode::End => {
                if let Modal::EventPicker { cursor, .. } = &mut self.modal {
                    *cursor = total.saturating_sub(1);
                }
            }
            KeyCode::Char('t') | KeyCode::Char('T') => {
                // Cycle through tracks: None → first → … → None.
                let next: Option<String> = match &self.modal {
                    Modal::EventPicker { entries, track_filter, .. } => {
                        let mut tracks: Vec<String> = entries
                            .iter()
                            .filter_map(|e| e.track.clone())
                            .collect();
                        tracks.sort();
                        tracks.dedup();
                        cycle_track(track_filter.as_deref(), &tracks)
                    }
                    _ => None,
                };
                if let Modal::EventPicker {
                    track_filter,
                    cursor,
                    ..
                } = &mut self.modal
                {
                    *track_filter = next.clone();
                    *cursor = 0;
                    self.status = match next {
                        Some(t) => format!("event picker · track filter: `{t}`"),
                        None => "event picker · track filter: all".into(),
                    };
                }
            }
            KeyCode::Enter => {
                let Modal::EventPicker {
                    entries,
                    cursor,
                    track_filter,
                } = &self.modal
                else {
                    return;
                };
                let visible = visible_event_entries(entries, track_filter.as_deref());
                let Some(entry) = visible.get(*cursor).copied() else {
                    return;
                };
                let id = entry.id;
                let title = entry.title.clone();
                self.modal = Modal::None;
                if let Err(e) = self.open_paragraph_by_uuid(id) {
                    self.status = format!("event picker: couldn't open `{title}`: {e}");
                } else if !self.status.starts_with("orphan event") {
                    // open_paragraph_by_uuid leaves the orphan
                    // hint in `status` when applicable; preserve
                    // it instead of stomping with a redundant
                    // "opened event" message.
                    self.status = format!("opened event `{title}`");
                }
            }
            _ => {}
        }
    }

    fn open_diagnostics_list(&mut self) {
        if self.opened.is_none() {
            self.status = "F8 diagnostics: no paragraph open".into();
            return;
        }
        self.refresh_typst_diagnostics_for_opened();
        let count = self
            .opened
            .as_ref()
            .map(|d| d.typst_diagnostics.len())
            .unwrap_or(0);
        if count == 0 {
            self.status = "F8 diagnostics: no typst diagnostics in this buffer".into();
            return;
        }
        // 1.2.7+ — F8 now fires from any pane; pull focus
        // back to the editor so the Enter-jumps-to-line
        // behaviour lands in the right place.
        self.change_focus(Focus::Editor);
        self.modal = Modal::DiagnosticsList { cursor: 0 };
        self.status = format!(
            "diagnostics ({count}) · ↑↓ select · Enter jumps · Esc closes"
        );
    }

    fn diagnostics_list_handle_key(&mut self, key: KeyEvent) {
        let total = self
            .opened
            .as_ref()
            .map(|d| d.typst_diagnostics.len())
            .unwrap_or(0);
        if total == 0 {
            return;
        }
        match key.code {
            KeyCode::Up => {
                if let Modal::DiagnosticsList { cursor } = &mut self.modal {
                    if *cursor > 0 {
                        *cursor -= 1;
                    }
                }
            }
            KeyCode::Down => {
                if let Modal::DiagnosticsList { cursor } = &mut self.modal {
                    if *cursor + 1 < total {
                        *cursor += 1;
                    }
                }
            }
            KeyCode::Home => {
                if let Modal::DiagnosticsList { cursor } = &mut self.modal {
                    *cursor = 0;
                }
            }
            KeyCode::End => {
                if let Modal::DiagnosticsList { cursor } = &mut self.modal {
                    *cursor = total.saturating_sub(1);
                }
            }
            KeyCode::Enter => {
                let Modal::DiagnosticsList { cursor } = self.modal else {
                    return;
                };
                let Some(diag) = self
                    .opened
                    .as_ref()
                    .and_then(|d| d.typst_diagnostics.get(cursor).cloned())
                else {
                    return;
                };
                self.modal = Modal::None;
                if let Some(doc) = self.opened.as_mut() {
                    let row = diag.line.saturating_sub(1) as u16;
                    let col = diag.col.saturating_sub(1) as u16;
                    doc.textarea
                        .move_cursor(tui_textarea::CursorMove::Jump(row, col));
                }
                self.change_focus(Focus::Editor);
                self.status = format!(
                    "diag · line {}:{} — {}",
                    diag.line, diag.col, diag.message
                );
            }
            _ => {}
        }
    }

    /// 1.2.6+ — open the AI diff-review modal. Captures the
    /// current buffer as `before_lines` and the would-be
    /// result of the named action as `after_lines`. The
    /// modal renders both columns side-by-side; the dispatch
    /// handler invokes `apply_inference_direct` if the user
    /// accepts.
    fn open_ai_diff_review(&mut self, action: InferenceAction, raw: &str) {
        let Some(doc) = self.opened.as_ref() else {
            self.status =
                "no paragraph open — apply needs a focused paragraph".into();
            return;
        };
        let before_lines: Vec<String> = doc.textarea.lines().to_vec();
        // Compute what the buffer WOULD look like after this
        // action so the diff is faithful. Both Replace and
        // ReplaceCorrected go through `select_apply_text` so
        // a grammar-style response with markers / fence /
        // "Corrected" heading lands ONLY the discrete block,
        // never the surrounding commentary — even when the
        // user pressed `r` (the looser chord).
        let force = matches!(action, InferenceAction::ReplaceCorrected);
        let raw_len = raw.len();
        let (after_text, extracted) = match select_apply_text(raw, force) {
            Ok(pair) => pair,
            Err(msg) => {
                self.status = msg.into();
                return;
            }
        };
        let after_lines: Vec<String> = if after_text.is_empty() {
            vec![String::new()]
        } else {
            after_text.split('\n').map(String::from).collect()
        };
        self.modal = Modal::AiDiffReview {
            before_lines,
            after_lines,
            action,
            scroll: 0,
            post_accept_snapshot: None,
            wrapped_total: 0,
        };
        self.status = if extracted {
            format!(
                "AI diff · ✂ extracted {}/{} chars · a accept · r reject",
                after_text.len(),
                raw_len,
            )
        } else {
            "AI diff · a accept · r reject · ↑↓ scroll".into()
        };
    }

    /// 1.2.11+ — same as `open_ai_diff_review` but
    /// stamps a `post_accept_snapshot` annotation
    /// on the modal so the apply step creates a
    /// labelled snapshot of the pre-rewrite
    /// buffer.  Used by the Ctrl+B Shift+M rhythm
    /// rewrite flow.
    fn open_ai_diff_review_with_snapshot(
        &mut self,
        action: InferenceAction,
        raw: &str,
        annotation: &str,
    ) {
        let Some(doc) = self.opened.as_ref() else {
            self.status =
                "no paragraph open — apply needs a focused paragraph".into();
            return;
        };
        let before_lines: Vec<String> = doc.textarea.lines().to_vec();
        let force = matches!(action, InferenceAction::ReplaceCorrected);
        let raw_len = raw.len();
        let (after_text, extracted) = match select_apply_text(raw, force) {
            Ok(pair) => pair,
            Err(msg) => {
                self.status = msg.into();
                return;
            }
        };
        let after_lines: Vec<String> = if after_text.is_empty() {
            vec![String::new()]
        } else {
            after_text.split('\n').map(String::from).collect()
        };
        self.modal = Modal::AiDiffReview {
            before_lines,
            after_lines,
            action,
            scroll: 0,
            post_accept_snapshot: Some(annotation.to_string()),
            wrapped_total: 0,
        };
        self.status = if extracted {
            format!(
                "rhythm rewrite · ✂ extracted {}/{} chars · a accept (snapshot + replace) · r reject",
                after_text.len(),
                raw_len,
            )
        } else {
            "rhythm rewrite · a accept (snapshot + replace) · r reject · ↑↓ scroll".into()
        };
    }

    /// 1.3.9+ — open the diff review with an ALREADY-FINAL after-text (no
    /// `select_apply_text` extraction). Used by the span-scoped editorial fix:
    /// the reply phrase has already been cleaned + spliced into the full
    /// paragraph, so the diff shows the whole paragraph and the apply lands it
    /// verbatim under an F6-discoverable snapshot.
    fn open_ai_diff_review_final(
        &mut self,
        action: InferenceAction,
        after_text: &str,
        annotation: &str,
    ) {
        let Some(doc) = self.opened.as_ref() else {
            self.status = "no paragraph open — apply needs a focused paragraph".into();
            return;
        };
        let before_lines: Vec<String> = doc.textarea.lines().to_vec();
        let after_lines: Vec<String> = if after_text.is_empty() {
            vec![String::new()]
        } else {
            after_text.split('\n').map(String::from).collect()
        };
        self.modal = Modal::AiDiffReview {
            before_lines,
            after_lines,
            action,
            scroll: 0,
            post_accept_snapshot: Some(annotation.to_string()),
            wrapped_total: 0,
        };
        self.status =
            "editorial fix (phrase) · a accept (snapshot + replace) · r reject · ↑↓ scroll".into();
    }

    /// Commit step for `Modal::AiDiffReview`. `after_text` is
    /// the buffer content the user accepted; `refocus_editor`
    /// jumps focus back to the editor pane (used by the `e`
    /// chord). Mirrors the in-place mutation that the
    /// pre-1.2.6 direct path did.
    fn apply_ai_diff_accepted(
        &mut self,
        action: InferenceAction,
        after_text: String,
        refocus_editor: bool,
    ) {
        let Some(doc) = self.opened.as_mut() else {
            return;
        };
        let baseline = doc.textarea.lines().to_vec();
        let lines: Vec<String> = if after_text.is_empty() {
            vec![String::new()]
        } else {
            after_text.split('\n').map(String::from).collect()
        };
        let mut new_ta = TextArea::new(lines);
        new_ta.set_cursor_line_style(
            Style::default().add_modifier(Modifier::REVERSED),
        );
        new_ta.set_line_number_style(
            Style::default().fg(self.theme.line_number_fg),
        );
        doc.textarea = new_ta;
        // ReplaceCorrected reuses the grammar-change highlight;
        // plain Replace doesn't (the user opted in to a full
        // rewrite, not a copy-edit).
        if matches!(action, InferenceAction::ReplaceCorrected) {
            doc.correction_baseline = Some(baseline);
        } else {
            doc.correction_baseline = None;
        }
        doc.dirty = true;
        doc.last_activity = std::time::Instant::now();
        // 1.2.12+ Phase D — diff-apply replaces the entire
        // buffer, so any cached whatlang detection is stale
        // by construction.  Force a fresh detection
        // unconditionally — the function short-circuits when
        // the effective mode is `book_defined` or the new
        // body is below the min-chars threshold.
        self.detect_paragraph_language();
        self.status = format!("AI diff: accepted ({})", action.label());
        if refocus_editor {
            self.change_focus(Focus::Editor);
        }
    }

    /// Ctrl+V N (1.2.5+) — move the editor cursor to the next
    /// typst diagnostic in the open buffer. Wraps around at the
    /// end. Refreshes the diagnostics cache up-front so the user
    /// always navigates against the current buffer state, even
    /// if they haven't paused long enough for the idle recheck
    /// to fire.
    fn jump_to_next_diagnostic(&mut self) {
        if self.opened.is_none() {
            self.status = "next diag: no paragraph open".into();
            return;
        }
        // Force a fresh recheck — keeps the navigation honest
        // when the user has been typing fast.
        self.refresh_typst_diagnostics_for_opened();
        let Some(doc) = self.opened.as_mut() else {
            return;
        };
        if doc.typst_diagnostics.is_empty() {
            self.status = "next diag: no typst diagnostics in this buffer".into();
            return;
        }
        // Cursor in tui-textarea is (row, col), both 0-based.
        // TypstDiagnostic.line/col are 1-based; normalise for
        // comparison.
        let (cur_row, cur_col) = doc.textarea.cursor();
        let cur1 = (cur_row + 1, cur_col + 1);
        // Find the first diagnostic strictly past the cursor.
        // Ties on the same line go to the higher column.
        let mut sorted_idxs: Vec<usize> = (0..doc.typst_diagnostics.len()).collect();
        sorted_idxs.sort_by_key(|&i| {
            let d = &doc.typst_diagnostics[i];
            (d.line, d.col)
        });
        let next = sorted_idxs.iter().copied().find(|&i| {
            let d = &doc.typst_diagnostics[i];
            (d.line, d.col) > cur1
        });
        let chosen = match next {
            Some(i) => i,
            None => {
                // Wrap to the first.
                sorted_idxs[0]
            }
        };
        let target = doc.typst_diagnostics[chosen].clone();
        let row = target.line.saturating_sub(1) as u16;
        let col = target.col.saturating_sub(1) as u16;
        doc.textarea
            .move_cursor(tui_textarea::CursorMove::Jump(row, col));
        let total = doc.typst_diagnostics.len();
        let wrapped_note = if next.is_none() && total > 1 {
            " (wrapped)"
        } else {
            ""
        };
        self.status = format!(
            "diag {}/{}{wrapped_note}  line {}:{}  — {}",
            sorted_idxs.iter().position(|&i| i == chosen).unwrap_or(0) + 1,
            total,
            target.line,
            target.col,
            target.message,
        );
    }

    /// Promote the paragraph one ladder step if (a) the project
    /// has `goals.auto_promote_on_target = true`, (b) the
    /// paragraph carries a positive `target_words`, (c) the
    /// current `word_count` meets or exceeds it, and (d) the
    /// last auto-promote isn't already at the current status.
    /// On promotion: bumps `status` via `next_status`, sets
    /// `target_hit_at_status` to the new status, fires the
    /// status-change progress event, persists via
    /// `store.raw().update_metadata`.
    fn maybe_auto_promote_on_target(&mut self, id: Uuid, current_words: i64) {
        if !self.cfg.goals.auto_promote_on_target {
            return;
        }
        let node = match self.hierarchy.get(id) {
            Some(n) => n.clone(),
            None => return,
        };
        if node.kind != NodeKind::Paragraph {
            return;
        }
        let Some(target) = node.target_words.filter(|n| *n > 0) else {
            return;
        };
        if current_words < target as i64 {
            return;
        }
        let current_status = node.status.clone();
        // Idempotent: already promoted at this status → bail.
        if node.target_hit_at_status.as_deref() == current_status.as_deref() {
            return;
        }
        let promoted = next_status(current_status.as_deref()).to_string();
        let new_status = if promoted == "None" {
            None
        } else {
            Some(promoted.clone())
        };
        let mut updated = node.clone();
        updated.status = new_status.clone();
        updated.target_hit_at_status = new_status.clone();
        updated.modified_at = chrono::Utc::now();
        if let Err(e) = self
            .store
            .raw()
            .update_metadata(id, updated.to_json())
        {
            tracing::warn!(
                target: "inkhaven::goal_promote",
                "auto-promote update_metadata failed: {e}"
            );
            return;
        }
        let from_label = display_status(current_status.as_deref())
            .to_ascii_lowercase();
        let to_label = promoted.to_ascii_lowercase();
        let book_id = self.book_of_node(id);
        crate::progress::record_status_change(
            id, book_id, &from_label, &to_label, current_words,
        );
        // hook.on_status_promoted fires from both auto-promote
        // and manual-cycle paths; see `cycle_paragraph_status`
        // for the signature and rationale.
        crate::scripting::hooks::fire(
            "hook.on_status_promoted",
            vec![
                rust_dynamic::value::Value::from_string(id.to_string()),
                rust_dynamic::value::Value::from_string(from_label.clone()),
                rust_dynamic::value::Value::from_string(to_label.clone()),
            ],
        );
        self.status = format!(
            "goal-hit: `{}` promoted {} → {}",
            node.title, from_label, to_label
        );
    }

    /// Resolve the user book a paragraph belongs to. Returns
    /// `None` for system-book content (Help / Scripts / Typst /
    /// …) which doesn't count toward writing goals.
    pub(crate) fn book_of_node(&self, id: Uuid) -> Option<Uuid> {
        let node = self.hierarchy.get(id)?;
        let book = self
            .hierarchy
            .ancestors(node)
            .into_iter()
            .find(|a| a.kind == NodeKind::Book)?;
        if book.system_tag.is_some() {
            return None;
        }
        Some(book.id)
    }

    // -------- drawing -----------------------------------------------------

    fn draw(&mut self, f: &mut ratatui::Frame) {
        // Typewriter mode: hide every pane except the editor. The
        // editor's own header still shows L/C, word count, edited
        // ago, and the status badge — so the user gets the writing-
        // critical metadata without the surrounding panes. Modals
        // (Quick-ref, Book info, etc.) still float on top, which
        // makes Ctrl+B H usable mid-flow.
        if self.typewriter_mode {
            let area = f.area();
            // Empty pane rects mean the mouse handler skips everything
            // hidden (clicks fall through to the editor's own rect).
            self.layout_search = Rect::default();
            self.layout_tree = Rect::default();
            self.layout_editor = area;
            self.layout_ai = Rect::default();
            self.layout_ai_prompt = Rect::default();
            self.draw_editor(f, area);
            if !matches!(self.modal, Modal::None) {
                self.draw_modal(f, f.area());
            }
            return;
        }

        // 1.2.12+ Phase A — fullscreen two-paragraph
        // split-view.  Layout: 50/50 horizontal editor
        // panes; AI prompt at the bottom; one status
        // line.  Tree, search bar, and AI response pane
        // are hidden.  Renders whenever `split_view`
        // is on — when the secondary slot is empty,
        // the right pane shows a prominent help
        // placeholder so the user can see HOW to fill
        // it (Ctrl+V P picker, Ctrl+V Shift+B sibling-
        // book, Ctrl+V M bookmarks, tree-pane Shift+
        // Enter).  Without this, pressing Shift+F4 on
        // a fresh session looked like a no-op.
        if self.split_view {
            // 1.2.12+ Phase D follow-up — tree pane
            // stays visible in split-view so the user
            // can navigate the hierarchy and pin
            // paragraphs to the right pane via the
            // existing tree-pane `Shift+Enter` chord.
            // The original Phase 0 plan hid the tree;
            // that turned out to confuse users —
            // they couldn't see how to populate the
            // secondary slot.  AI response pane
            // stays hidden (the two editors need
            // the room).  AI prompt input still
            // spans the bottom so `Ctrl+I` works
            // from either editor pane.
            let outer = Layout::default()
                .direction(Direction::Vertical)
                .constraints([
                    Constraint::Min(0),
                    Constraint::Length(3),
                    Constraint::Length(1),
                ])
                .split(f.area());
            // Tree pane: same fixed width the
            // standard layout uses (30 cols by
            // default).  Editors share the rest 50/50.
            let top = Layout::default()
                .direction(Direction::Horizontal)
                .constraints([
                    Constraint::Length(30),
                    Constraint::Percentage(50),
                    Constraint::Percentage(50),
                ])
                .split(outer[0]);
            self.layout_search = Rect::default();
            self.layout_tree = top[0];
            self.layout_editor = top[1];
            self.layout_ai = top[2];
            self.layout_ai_prompt = outer[1];
            self.draw_tree(f, top[0]);
            self.draw_editor(f, top[1]);
            if self.secondary.is_some() {
                self.draw_secondary_editor(f, top[2]);
            } else {
                self.draw_split_placeholder(f, top[2]);
            }
            self.draw_ai_prompt(f, outer[1]);
            self.draw_status(f, outer[2]);
            if !matches!(self.modal, Modal::None) {
                self.draw_modal(f, f.area());
            }
            return;
        }

        if self.ai_fullscreen {
            // Layout: most of the screen split 50/50 (AI pane | chat
            // history); AI prompt at the bottom; one status line.
            // Tree, editor, and search bar are hidden.
            let outer = Layout::default()
                .direction(Direction::Vertical)
                .constraints([
                    Constraint::Min(0),
                    Constraint::Length(3),
                    Constraint::Length(1),
                ])
                .split(f.area());
            let top = Layout::default()
                .direction(Direction::Horizontal)
                .constraints([
                    Constraint::Percentage(50),
                    Constraint::Percentage(50),
                ])
                .split(outer[0]);
            self.layout_search = Rect::default();
            self.layout_tree = Rect::default();
            self.layout_editor = Rect::default();
            self.layout_ai = top[0];
            self.layout_ai_prompt = outer[1];
            self.draw_ai(f, top[0]);
            self.draw_chat_history(f, top[1]);
            self.draw_ai_prompt(f, outer[1]);
            self.draw_status(f, outer[2]);
            if !matches!(self.modal, Modal::None) {
                self.draw_modal(f, f.area());
            }
            return;
        }

        let outer = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Length(3),
                Constraint::Min(0),
                Constraint::Length(3),
                Constraint::Length(1),
            ])
            .split(f.area());

        let body = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([
                Constraint::Percentage(22),
                Constraint::Percentage(56),
                Constraint::Percentage(22),
            ])
            .split(outer[1]);

        // Cache pane rects for the mouse handler. Done before drawing so
        // a draw that bails (e.g. tiny terminal) still leaves the rects
        // self-consistent for whatever pane managed to render.
        self.layout_search = outer[0];
        self.layout_tree = body[0];
        self.layout_editor = body[1];
        self.layout_ai = body[2];
        self.layout_ai_prompt = outer[2];

        self.draw_search_bar(f, outer[0]);
        self.draw_tree(f, body[0]);
        // 1.2.12+ — similar-mode display is now gated
        // on `App.similar_mode` (set by Ctrl+V S),
        // not on `secondary.is_some()`.  This keeps
        // Shift+F4 split-view pins from displacing
        // the AI pane in the standard layout after
        // exit.  See `Documentation/PROPOSALS/SPLIT_VIEW.md`.
        if self.similar_mode && self.secondary.is_some() {
            // Similar-paragraph mode: AI pane is repurposed as
            // the second editor. Both panes carve off the bottom
            // row of their rect for the full slug-path footer
            // the spec asks for.
            let primary_rect = body[1];
            let footer_h: u16 = 1;
            let primary_editor_rect = Rect {
                x: primary_rect.x,
                y: primary_rect.y,
                width: primary_rect.width,
                height: primary_rect.height.saturating_sub(footer_h),
            };
            let primary_footer_rect = Rect {
                x: primary_rect.x,
                y: primary_rect.y + primary_rect.height.saturating_sub(footer_h),
                width: primary_rect.width,
                height: footer_h,
            };
            self.draw_editor(f, primary_editor_rect);
            self.draw_primary_pane_footer(f, primary_footer_rect);
            self.draw_secondary_editor(f, body[2]);
        } else {
            self.draw_editor(f, body[1]);
            self.draw_ai(f, body[2]);
        }
        self.draw_ai_prompt(f, outer[2]);
        self.draw_status(f, outer[3]);

        if self.show_results_overlay {
            self.draw_search_overlay(f, outer[1]);
        }
        if self.show_prompt_picker {
            self.draw_prompt_picker(f, f.area());
        }

        // Modal renders last so it floats over everything.
        if !matches!(self.modal, Modal::None) {
            self.draw_modal(f, f.area());
        }
    }


    fn bund_pane_handle_key(&mut self, key: KeyEvent) {
        // Esc closes the pane (top of handle_modal_key already
        // covers it). Here we just handle scrolling.
        let Modal::BundPane { lines, scroll, .. } = &mut self.modal else {
            return;
        };
        let total = lines.len();
        let page: usize = 12; // approximate visible window
        match key.code {
            KeyCode::Up => {
                *scroll = scroll.saturating_sub(1);
            }
            KeyCode::Down => {
                if *scroll + 1 < total {
                    *scroll += 1;
                }
            }
            KeyCode::PageUp => {
                *scroll = scroll.saturating_sub(page);
            }
            KeyCode::PageDown => {
                let max = total.saturating_sub(page.max(1));
                *scroll = (*scroll + page).min(max);
            }
            KeyCode::Home => {
                *scroll = 0;
            }
            KeyCode::End => {
                *scroll = total.saturating_sub(page);
            }
            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                // Ctrl+C inside the pane clears its buffer — convenient
                // when the pane keeps catching subsequent script output.
                lines.clear();
                *scroll = 0;
            }
            _ => {}
        }
    }

    fn script_picker_handle_key(&mut self, key: KeyEvent) {
        // Snapshot fields we need without holding the borrow
        // across `run_script_by_id` (which takes &mut self).
        let (selected_id, total, was_a_toggle, was_enter): (Option<Uuid>, usize, bool, bool) = {
            let Modal::ScriptPicker {
                entries,
                cursor,
                scroll,
                scope,
                ..
            } = &mut self.modal
            else {
                return;
            };
            let total = entries.len();
            let page: usize = 12;
            let mut a_toggle = false;
            let mut enter = false;
            let mut selected: Option<Uuid> = None;
            match key.code {
                KeyCode::Up => {
                    if *cursor > 0 {
                        *cursor -= 1;
                    }
                }
                KeyCode::Down => {
                    if *cursor + 1 < total {
                        *cursor += 1;
                    }
                }
                KeyCode::PageUp => {
                    *cursor = cursor.saturating_sub(page);
                }
                KeyCode::PageDown => {
                    *cursor = (*cursor + page).min(total.saturating_sub(1).max(0));
                }
                KeyCode::Home => *cursor = 0,
                KeyCode::End => *cursor = total.saturating_sub(1),
                KeyCode::Char('a') | KeyCode::Char('A') => {
                    *scope = match scope {
                        ScriptPickerScope::Branch => ScriptPickerScope::ScriptsBook,
                        ScriptPickerScope::ScriptsBook => ScriptPickerScope::Branch,
                    };
                    a_toggle = true;
                }
                KeyCode::Enter => {
                    if let Some(e) = entries.get(*cursor) {
                        selected = Some(e.id);
                    }
                    enter = true;
                }
                _ => {}
            }
            // Keep cursor visible (cheap: clamp scroll).
            if *cursor < *scroll {
                *scroll = *cursor;
            } else if *cursor >= *scroll + page {
                *scroll = *cursor + 1 - page;
            }
            (selected, total, a_toggle, enter)
        };

        if was_a_toggle {
            // Rebuild entries against the new scope.
            let new_scope = match &self.modal {
                Modal::ScriptPicker { scope, .. } => *scope,
                _ => return,
            };
            let new_entries = self.collect_script_entries(new_scope);
            if let Modal::ScriptPicker {
                entries,
                cursor,
                scroll,
                ..
            } = &mut self.modal
            {
                *entries = new_entries;
                *cursor = 0;
                *scroll = 0;
            }
            self.status = match new_scope {
                ScriptPickerScope::Branch => "bund: branch scope".into(),
                ScriptPickerScope::ScriptsBook => "bund: Scripts book scope".into(),
            };
            return;
        }

        if was_enter {
            self.modal = Modal::None;
            if let Some(id) = selected_id {
                if let Err(e) = self.bund_run_script_by_id(id) {
                    self.status = format!("bund: {e}");
                }
            } else if total == 0 {
                self.status = "bund: no script to run".into();
            }
        }
    }

    /// Load the Script node `id`, eval its body against Adam,
    /// and route the result (or error) to the status bar — or
    /// to the Bund pane if one is open.
    fn bund_run_script_by_id(&mut self, id: Uuid) -> std::result::Result<(), String> {
        let node = self
            .hierarchy
            .get(id)
            .ok_or_else(|| format!("script {id} not found"))?
            .clone();
        if node.kind != NodeKind::Script {
            return Err(format!("“{}” is not a Script node", node.title));
        }
        let bytes = self
            .store
            .get_content(node.id)
            .map_err(|e| format!("load {}: {e}", node.title))?
            .unwrap_or_default();
        let body = String::from_utf8(bytes)
            .map_err(|e| format!("script “{}” not utf-8: {e}", node.title))?;
        if body.trim().is_empty() {
            self.status = format!("bund: “{}” is empty", node.title);
            return Ok(());
        }
        match self.scripting_eval(&body) {
            Ok(out) => {
                self.status = format_eval_output(&out, Some(&node.title));
                Ok(())
            }
            Err(e) => Err(format!("eval “{}” failed — {e:#}", node.title)),
        }
    }

    fn link_picker_handle_key(&mut self, key: KeyEvent) {
        // Collect intent; close over the modal's mutable state.
        let (owner, target_to_remove, target_to_open) = {
            let Modal::LinkPicker { owner, entries, cursor, scroll } = &mut self.modal else {
                return;
            };
            let total = entries.len();
            let page: usize = 12;
            let mut target_to_remove: Option<Uuid> = None;
            let mut target_to_open: Option<Uuid> = None;
            match key.code {
                KeyCode::Up => {
                    if *cursor > 0 {
                        *cursor -= 1;
                    }
                }
                KeyCode::Down => {
                    if *cursor + 1 < total {
                        *cursor += 1;
                    }
                }
                KeyCode::PageUp => *cursor = cursor.saturating_sub(page),
                KeyCode::PageDown => {
                    *cursor = (*cursor + page).min(total.saturating_sub(1));
                }
                KeyCode::Home => *cursor = 0,
                KeyCode::End => *cursor = total.saturating_sub(1),
                KeyCode::Char('D') | KeyCode::Char('d') | KeyCode::Delete => {
                    if let Some(e) = entries.get(*cursor) {
                        target_to_remove = Some(e.id);
                    }
                }
                // 1.2.4+: Enter opens the linked paragraph in the
                // editor (autosaving the current buffer first via
                // `open_search_result` → `load_paragraph` →
                // `save_current`). Tree cursor follows. Modal closes.
                KeyCode::Enter => {
                    if let Some(e) = entries.get(*cursor) {
                        target_to_open = Some(e.id);
                    }
                }
                _ => {}
            }
            // Keep cursor visible.
            if *cursor < *scroll {
                *scroll = *cursor;
            } else if *cursor >= *scroll + page {
                *scroll = *cursor + 1 - page;
            }
            (*owner, target_to_remove, target_to_open)
        };

        if let Some(target) = target_to_open {
            // Close the modal first so any status message the
            // load flow sets isn't immediately overwritten by a
            // modal-redraw cycle.
            self.modal = Modal::None;
            // `open_search_result` does exactly what we want:
            // moves the tree cursor onto the target row, then
            // loads the paragraph (which autosaves the previous
            // buffer if it was dirty).
            self.open_search_result(target);
            return;
        }

        if let Some(target) = target_to_remove {
            match self.remove_paragraph_link(owner, target) {
                Ok(true) => {
                    self.status = "link removed".into();
                    // Rebuild the modal entries from the fresh
                    // hierarchy; close the modal if no links remain.
                    let entries = self.collect_link_entries(owner);
                    if entries.is_empty() {
                        self.modal = Modal::None;
                    } else if let Modal::LinkPicker { entries: e, cursor, scroll, .. } =
                        &mut self.modal
                    {
                        *cursor = (*cursor).min(entries.len() - 1);
                        *scroll = (*scroll).min(*cursor);
                        *e = entries;
                    }
                }
                Ok(false) => {
                    self.status = "link not found (stale view)".into();
                }
                Err(e) => {
                    self.status = format!("link remove: {e}");
                }
            }
        }
    }

    fn fuzzy_paragraph_picker_handle_key(&mut self, key: KeyEvent) {
        // 1.2.12+ Phase B — Shift+Enter routes the
        // pick to the split-view secondary slot;
        // plain Enter still opens into the primary.
        let pin_to_secondary =
            key.modifiers.contains(KeyModifiers::SHIFT);
        let to_open = {
            let Modal::FuzzyParagraphPicker { input, entries, cursor, scroll } =
                &mut self.modal
            else {
                return;
            };
            let matches = fuzzy_filter_entries(entries, input.as_str());
            let total = matches.len();
            let page: usize = 12;
            let mut to_open: Option<Uuid> = None;
            match key.code {
                KeyCode::Up => {
                    if *cursor > 0 {
                        *cursor -= 1;
                    }
                }
                KeyCode::Down => {
                    if *cursor + 1 < total {
                        *cursor += 1;
                    }
                }
                KeyCode::PageUp => *cursor = cursor.saturating_sub(page),
                KeyCode::PageDown => {
                    *cursor = (*cursor + page).min(total.saturating_sub(1));
                }
                KeyCode::Enter => {
                    if let Some(idx) = matches.get(*cursor).copied() {
                        to_open = Some(entries[idx].id);
                    }
                }
                _ => {
                    // Don't treat Shift+printable as
                    // text input here — the Shift
                    // modifier is the split-view hint;
                    // Shift+a etc. is just a capital
                    // letter and handle_text_input_key
                    // already lowercases / treats it
                    // correctly.
                    handle_text_input_key(input, key);
                    // Reset cursor on input edit; matches list
                    // may have shifted.
                    *cursor = 0;
                    *scroll = 0;
                }
            }
            if *cursor < *scroll {
                *scroll = *cursor;
            } else if *cursor >= *scroll + page {
                *scroll = *cursor + 1 - page;
            }
            to_open
        };

        if let Some(id) = to_open {
            self.modal = Modal::None;
            self.dispatch_picker_accept(id, pin_to_secondary);
        }
    }

    fn bookmark_picker_handle_key(&mut self, key: KeyEvent) {
        // 1.2.12+ Phase B — Shift+Enter routes to the
        // split-view secondary slot.
        let pin_to_secondary =
            key.modifiers.contains(KeyModifiers::SHIFT);
        let (id_to_unbookmark, id_to_open) = {
            let Modal::BookmarkPicker { entries, cursor, scroll } = &mut self.modal else {
                return;
            };
            let total = entries.len();
            let page: usize = 12;
            let mut to_unbookmark: Option<Uuid> = None;
            let mut to_open: Option<Uuid> = None;
            match key.code {
                KeyCode::Up => {
                    if *cursor > 0 {
                        *cursor -= 1;
                    }
                }
                KeyCode::Down => {
                    if *cursor + 1 < total {
                        *cursor += 1;
                    }
                }
                KeyCode::PageUp => *cursor = cursor.saturating_sub(page),
                KeyCode::PageDown => {
                    *cursor = (*cursor + page).min(total.saturating_sub(1));
                }
                KeyCode::Home => *cursor = 0,
                KeyCode::End => *cursor = total.saturating_sub(1),
                KeyCode::Enter => {
                    if let Some(e) = entries.get(*cursor) {
                        to_open = Some(e.id);
                    }
                }
                KeyCode::Char('D') | KeyCode::Char('d') | KeyCode::Delete => {
                    if let Some(e) = entries.get(*cursor) {
                        to_unbookmark = Some(e.id);
                    }
                }
                _ => {}
            }
            if *cursor < *scroll {
                *scroll = *cursor;
            } else if *cursor >= *scroll + page {
                *scroll = *cursor + 1 - page;
            }
            (to_unbookmark, to_open)
        };

        if let Some(id) = id_to_open {
            self.modal = Modal::None;
            self.dispatch_picker_accept(id, pin_to_secondary);
            return;
        }

        if let Some(id) = id_to_unbookmark {
            if let Some(node) = self.hierarchy.get(id).cloned() {
                let mut updated = node.clone();
                updated.bookmark = false;
                updated.modified_at = chrono::Utc::now();
                if let Err(e) = self
                    .store
                    .raw()
                    .update_metadata(id, updated.to_json())
                {
                    self.status = format!("bookmark clear: {e}");
                    return;
                }
                self.reload_hierarchy();
                let entries = self.collect_bookmark_entries();
                if entries.is_empty() {
                    self.modal = Modal::None;
                    self.status = "bookmark cleared · no bookmarks left".into();
                } else if let Modal::BookmarkPicker { entries: e, cursor, scroll } =
                    &mut self.modal
                {
                    *cursor = (*cursor).min(entries.len() - 1);
                    *scroll = (*scroll).min(*cursor);
                    *e = entries;
                    self.status = "bookmark cleared".into();
                }
            }
        }
    }

    fn backlink_picker_handle_key(&mut self, key: KeyEvent) {
        let (target, source_to_unlink, target_to_open) = {
            let Modal::BacklinkPicker { target, entries, cursor, scroll } = &mut self.modal else {
                return;
            };
            let total = entries.len();
            let page: usize = 12;
            let mut source_to_unlink: Option<Uuid> = None;
            let mut target_to_open: Option<Uuid> = None;
            match key.code {
                KeyCode::Up => {
                    if *cursor > 0 {
                        *cursor -= 1;
                    }
                }
                KeyCode::Down => {
                    if *cursor + 1 < total {
                        *cursor += 1;
                    }
                }
                KeyCode::PageUp => *cursor = cursor.saturating_sub(page),
                KeyCode::PageDown => {
                    *cursor = (*cursor + page).min(total.saturating_sub(1));
                }
                KeyCode::Home => *cursor = 0,
                KeyCode::End => *cursor = total.saturating_sub(1),
                KeyCode::Char('D') | KeyCode::Char('d') | KeyCode::Delete => {
                    if let Some(e) = entries.get(*cursor) {
                        source_to_unlink = Some(e.id);
                    }
                }
                KeyCode::Enter => {
                    if let Some(e) = entries.get(*cursor) {
                        target_to_open = Some(e.id);
                    }
                }
                _ => {}
            }
            if *cursor < *scroll {
                *scroll = *cursor;
            } else if *cursor >= *scroll + page {
                *scroll = *cursor + 1 - page;
            }
            (*target, source_to_unlink, target_to_open)
        };

        if let Some(to_open) = target_to_open {
            self.modal = Modal::None;
            self.open_search_result(to_open);
            return;
        }

        if let Some(source) = source_to_unlink {
            // Remove the source's outgoing link to `target`. That's
            // what makes this the symmetric "delete" for backlinks:
            // the link metadata lives on the source paragraph, so
            // mutating it from the backlinks view is honest about
            // what changes on disk.
            match self.remove_paragraph_link(source, target) {
                Ok(true) => {
                    self.status = "backlink removed (source's outgoing link to current)".into();
                    let entries = self.collect_backlink_entries(target);
                    if entries.is_empty() {
                        self.modal = Modal::None;
                    } else if let Modal::BacklinkPicker { entries: e, cursor, scroll, .. } =
                        &mut self.modal
                    {
                        *cursor = (*cursor).min(entries.len() - 1);
                        *scroll = (*scroll).min(*cursor);
                        *e = entries;
                    }
                }
                Ok(false) => {
                    self.status = "backlink not found (stale view)".into();
                }
                Err(e) => {
                    self.status = format!("backlink remove: {e}");
                }
            }
        }
    }

    fn similar_picker_handle_key(&mut self, key: KeyEvent) {
        let (selected_id, total, was_enter) = {
            let Modal::SimilarPicker { entries, cursor, scroll } = &mut self.modal else {
                return;
            };
            let total = entries.len();
            let page: usize = 12;
            let mut enter = false;
            let mut selected: Option<Uuid> = None;
            match key.code {
                KeyCode::Up => {
                    if *cursor > 0 {
                        *cursor -= 1;
                    }
                }
                KeyCode::Down => {
                    if *cursor + 1 < total {
                        *cursor += 1;
                    }
                }
                KeyCode::PageUp => *cursor = cursor.saturating_sub(page),
                KeyCode::PageDown => {
                    *cursor = (*cursor + page).min(total.saturating_sub(1).max(0));
                }
                KeyCode::Home => *cursor = 0,
                KeyCode::End => *cursor = total.saturating_sub(1),
                KeyCode::Enter => {
                    if let Some(e) = entries.get(*cursor) {
                        selected = Some(e.id);
                    }
                    enter = true;
                }
                _ => {}
            }
            if *cursor < *scroll {
                *scroll = *cursor;
            } else if *cursor >= *scroll + page {
                *scroll = *cursor + 1 - page;
            }
            (selected, total, enter)
        };

        if was_enter {
            self.modal = Modal::None;
            if let Some(id) = selected_id {
                match self.load_secondary_paragraph(id) {
                    Ok(()) => {
                        // 1.2.12+ — entering similar-mode
                        // explicitly so the standard-layout
                        // renderer draws secondary in the
                        // right column (instead of the AI
                        // pane).  Was implicit from
                        // `secondary.is_some()` pre-1.2.12.
                        self.similar_mode = true;
                    }
                    Err(e) => {
                        self.status = format!("similar: {e}");
                    }
                }
            } else if total == 0 {
                self.status = "similar: nothing to open".into();
            }
        }
    }

    /// Mutate `target_words` on the node `id` and persist via
    /// `store.raw().update_metadata`. Used by both the Ctrl+V T
    /// modal and the `ink.paragraph.set_target` Bund word.
    /// Setting target to None also clears `target_hit_at_status`
    /// so re-enabling the goal starts fresh.
    pub(crate) fn set_paragraph_target_now(
        &mut self,
        id: Uuid,
        target: Option<i32>,
    ) -> std::result::Result<(), String> {
        let node = self
            .hierarchy
            .get(id)
            .ok_or_else(|| format!("paragraph {id} not in hierarchy"))?;
        if node.kind != NodeKind::Paragraph {
            return Err(format!("`{}` is not a paragraph", node.title));
        }
        let mut updated = node.clone();
        updated.target_words = target;
        if target.is_none() {
            updated.target_hit_at_status = None;
        }
        self.store
            .raw()
            .update_metadata(id, updated.to_json())
            .map_err(|e| format!("store update: {e}"))?;
        self.reload_hierarchy();
        Ok(())
    }

    /// Open the writing-progress modal. Forces a cache refresh
    /// so the user always sees fresh numbers (the per-redraw
    /// path stays cheap by reading the cache).
    fn open_progress_modal(&mut self) {
        self.refresh_progress_cache();
        self.modal = Modal::Progress { scroll: 0 };
        self.status = "progress: ↑↓ scroll · Esc close · r refresh".into();
    }

    fn progress_modal_handle_key(&mut self, key: KeyEvent) {
        let Modal::Progress { scroll } = &mut self.modal else {
            return;
        };
        match key.code {
            KeyCode::Up => *scroll = scroll.saturating_sub(1),
            KeyCode::Down => *scroll += 1,
            KeyCode::PageUp => *scroll = scroll.saturating_sub(8),
            KeyCode::PageDown => *scroll += 8,
            KeyCode::Home => *scroll = 0,
            KeyCode::End => *scroll += 100, // clamped by renderer
            KeyCode::Char('r') | KeyCode::Char('R') => {
                self.refresh_progress_cache();
                self.status = "progress: refreshed".into();
            }
            _ => {}
        }
    }

    fn pane_block<'a>(&self, title: &'a str, focus: Focus) -> Block<'a> {
        self.pane_block_line(Line::from(title), focus)
    }

    /// Same as `pane_block` but accepts a pre-built styled `Line` as the
    /// title — used by the AI pane to colourise the `scope=` / `infer=`
    /// mode chips.
    fn pane_block_line<'a>(&self, title: Line<'a>, focus: Focus) -> Block<'a> {
        let border_color = if self.focus == focus {
            self.theme.border_focused
        } else {
            self.theme.border_unfocused
        };
        Block::default()
            .borders(Borders::ALL)
            .title(title)
            .border_style(
                Style::default()
                    .fg(border_color)
                    .add_modifier(Modifier::BOLD),
            )
            .style(
                Style::default()
                    .bg(self.theme.pane_bg)
                    .fg(self.theme.pane_fg),
            )
    }

    /// Editor pane block. Border colour carries the document's clean/dirty
    /// state when the pane has focus: green when saved, yellow when dirty,
    /// teal when the open paragraph is read-only (Help subtree). Unfocused
    /// uses the theme's neutral border colour.
    #[allow(dead_code)] // string-title convenience; the live renderer
    // uses `editor_block_line` for the coloured cursor read-out, but the
    // string overload stays available for non-styled callers.
    /// Centralized focus change. When leaving the Editor pane, autosave the
    /// open paragraph (best-effort) so unsaved edits are persisted even
    /// across Tab cycles, Ctrl+1..5 jumps, or any other focus shift. The
    /// underlying `save_current` writes to disk + bdslib + re-embeds.
    fn change_focus(&mut self, new: Focus) {
        if self.focus == Focus::Editor && new != Focus::Editor {
            // Focus-out also counts as "I'm done reviewing the
            // corrections" — drop the highlight so the next time the
            // user comes back the paragraph reads clean.
            if let Some(doc) = self.opened.as_mut() {
                doc.correction_baseline = None;
            }
            if self.opened.as_ref().is_some_and(|d| d.dirty) {
                let _ = self.save_current();
            }
            // Snapshot the cursor before defocusing so re-opening this
            // paragraph (now or in a future run) lands the cursor back where
            // the user left it. Persisting to disk here makes the position
            // survive a crash or kill, not just a graceful exit.
            self.snapshot_open_paragraph_cursor();
            if let Err(e) = self.save_session() {
                tracing::warn!("focus-loss session save failed: {e}");
            }
            // Typewriter SFX — "remove page from machine" when the
            // editor pane loses focus. No-op when sound is disabled or
            // the host has no audio device.
            if let Some(sp) = &self.sound {
                sp.play_focus_out();
            }
        }
        self.focus = new;
    }

    fn toggle_chat_selection_mode(&mut self) {
        if self.chat_selection.is_some() {
            self.chat_selection = None;
            self.status = "chat selection mode off".into();
            return;
        }
        if self.chat_history.is_empty() {
            self.status = "chat history is empty — nothing to select".into();
            return;
        }
        // Start at the newest turn — the assistant reply you just
        // received is what most users want to copy.
        let turn = self.chat_history.len() - 1;
        self.chat_selection = Some(ChatSelectionState { turn });
        // Dismiss the search highlights so the selection's block bg
        // isn't fighting for visibility.
        self.chat_search = None;
        // Reset scroll — the renderer auto-centres on the selected
        // turn anyway.
        self.chat_history_scroll = 0;
        self.status =
            "chat selection mode · ↑↓ navigate · c=copy · t=insert into editor · Esc to exit".into();
    }

    /// Open the chat-history search query modal. Pre-populates the
    /// input with the previous query (if any) so re-search is a
    /// single Enter.
    fn open_chat_search_prompt(&mut self) {
        if self.chat_history.is_empty() {
            self.status = "chat history is empty — nothing to search".into();
            return;
        }
        let mut input = TextInput::new();
        if let Some(prev) = &self.chat_search {
            for c in prev.query.chars() {
                input.insert_char(c);
            }
        }
        self.modal = Modal::ChatSearchPrompt { input };
        self.status =
            "Search chat history · Enter to start (newest first) · Ctrl+X next (older) · Esc cancel".into();
    }

    /// Apply the just-submitted query. Empty query clears any active
    /// search. The `current` index starts at the LAST match — the
    /// most recent / closest-to-the-bottom hit — per the spec.
    fn commit_chat_search(&mut self, query: String) {
        if query.is_empty() {
            self.chat_search = None;
            self.status = "chat search: empty query — cleared".into();
            return;
        }
        let total = self.chat_search_matches(&query).len();
        if total == 0 {
            self.chat_search = None;
            self.status = format!("chat search: no match for `{query}`");
            return;
        }
        self.chat_search = Some(ChatSearchState {
            query: query.clone(),
            current: total - 1, // newest match (last in match order)
        });
        // Recompute scroll so the centre lands on the match.
        // draw_chat_history handles this from the state.
        self.chat_history_scroll = 0;
        self.status = format!("chat search: `{query}` · 1/{total} (newest)");
    }

    /// Step the chat-search cursor one match toward older history.
    /// Wraps from oldest back to newest. Matches are recomputed each
    /// call to handle terminal resize / streaming-token arrival.
    fn advance_chat_search(&mut self) {
        let Some((query, current)) = self
            .chat_search
            .as_ref()
            .map(|s| (s.query.clone(), s.current))
        else {
            return;
        };
        let total = self.chat_search_matches(&query).len();
        if total == 0 {
            // Live history may have lost the matches we had — clear.
            self.chat_search = None;
            self.status = "chat search: no matches in current history".into();
            return;
        }
        let new_current = (current + total - 1) % total;
        if let Some(search) = self.chat_search.as_mut() {
            search.current = new_current;
        }
        self.status = format!(
            "chat search: `{query}` · {}/{}",
            new_current + 1,
            total
        );
    }

    /// Build the chat-history pane's Lines exactly as
    /// `draw_chat_history` does — same iteration, same markdown
    /// rendering, same headers. Returns both the lines and per-turn
    /// `(line_start..line_end)` ranges so the chat-selection mode
    /// can highlight the active turn as a block.
    fn build_chat_history_lines(&self) -> (Vec<Line<'static>>, Vec<std::ops::Range<usize>>) {
        let mut lines: Vec<Line<'static>> = Vec::new();
        let mut turn_ranges: Vec<std::ops::Range<usize>> = Vec::new();
        let user_style = Style::default()
            .fg(self.theme.ai_scope_fg)
            .add_modifier(Modifier::BOLD);
        let assistant_style = Style::default()
            .fg(self.theme.ai_infer_fg)
            .add_modifier(Modifier::BOLD);
        for (i, turn) in self.chat_history.iter().enumerate() {
            let turn_start = lines.len();
            match turn {
                ChatTurn::User(text) => {
                    if i > 0 {
                        lines.push(Line::from(""));
                    }
                    lines.push(Line::from(Span::styled(
                        "❯ User".to_string(),
                        user_style,
                    )));
                    for line in text.lines() {
                        lines.push(Line::from(format!("  {line}")));
                    }
                }
                ChatTurn::Assistant(text) => {
                    lines.push(Line::from(Span::styled(
                        "← Assistant".to_string(),
                        assistant_style,
                    )));
                    let rendered = super::markdown::render(text);
                    if rendered.is_empty() {
                        for line in text.lines() {
                            lines.push(Line::from(format!("  {line}")));
                        }
                    } else {
                        for l in rendered {
                            lines.push(l);
                        }
                    }
                }
            }
            turn_ranges.push(turn_start..lines.len());
        }
        (lines, turn_ranges)
    }

    /// One-line writing-progress summary for the right edge of
    /// the status bar. Empty when progress tracking is disabled
    /// (no store installed or no goals configured).
    /// 1.2.7+ — same content as the old text-only widget,
    /// but the `today` segment gets a colour + glyph based
    /// on goal state (green ✓ when over goal, amber while
    /// climbing, dim when still cold). The rest stays DIM
    /// so the writing-progress widget reads as a calm
    /// rightside chip with one always-bright element.
    fn progress_widget_spans(&self) -> Vec<Span<'static>> {
        let Some(snap) = self.progress_cache.as_ref() else {
            return Vec::new();
        };
        let dim = Style::default().add_modifier(Modifier::DIM);
        let mut out: Vec<Span<'static>> = Vec::new();
        let today = snap.project.today_words;
        let (today_text, today_style) = match snap.project.daily_goal {
            Some(goal) if goal > 0 => {
                let (glyph, style) = if today >= goal {
                    (
                        "✓",
                        Style::default()
                            .fg(Color::Green)
                            .add_modifier(Modifier::BOLD),
                    )
                } else if today > 0 {
                    (
                        "·",
                        Style::default().fg(Color::Yellow),
                    )
                } else {
                    (
                        "·",
                        dim,
                    )
                };
                (
                    format!("{glyph} today {today}/{goal}w"),
                    style,
                )
            }
            _ => {
                let style = if today > 0 {
                    Style::default().add_modifier(Modifier::BOLD)
                } else {
                    dim
                };
                (format!("today {today}w"), style)
            }
        };
        out.push(Span::styled(today_text, today_style));
        // Everything after `today` joins the dim trailing
        // chips via a single computed-on-the-fly string —
        // matches the pre-1.2.7 text layout for those
        // segments and keeps the widget tight.
        let trailing = self.progress_widget_trailing(snap);
        if !trailing.is_empty() {
            out.push(Span::styled(format!(" · {trailing}"), dim));
        }
        out.push(Span::raw(" "));
        out
    }

    /// Build the comma-joined trailing chip string (active
    /// time, streak, link count, book pace). Pulled out of
    /// `progress_widget_spans` so the `today` segment can
    /// own its own colour without copy-pasting the rest.
    fn progress_widget_trailing(
        &self,
        snap: &crate::progress::ProgressSnapshot,
    ) -> String {
        let mut parts: Vec<String> = Vec::new();
        // Active-time chip — bare duration when no goal is set,
        // `<spent>/<goal>` when there is one.
        let active_goal_secs = self.cfg.goals.active_minutes_daily.max(0) * 60;
        if active_goal_secs > 0 {
            parts.push(format!(
                "{}/{}",
                format_active_duration(snap.active_seconds_today),
                format_active_duration(active_goal_secs),
            ));
        } else if snap.active_seconds_today > 0 {
            parts.push(format_active_duration(snap.active_seconds_today));
        }
        if snap.streak.days > 0 {
            parts.push(format!("streak {}d", snap.streak.days));
        }
        // Outgoing-link count for the open paragraph (1.2.4+).
        // Only shown when > 0 to keep the line short on the
        // common "no links yet" case.
        if let Some(doc) = self.opened.as_ref() {
            if let Some(node) = self.hierarchy.get(doc.id) {
                let n = node.linked_paragraphs.len();
                if n > 0 {
                    parts.push(format!("links {n}"));
                }
            }
        }
        // Surface the focused book's pace if a deadline is set.
        if let Some(book) = snap.books.iter().find(|b| {
            self.opened
                .as_ref()
                .and_then(|d| self.book_of_node(d.id))
                .map_or(false, |bid| {
                    // Compare by title since BookProgress doesn't
                    // carry the uuid; book labels are unique
                    // within a project.
                    self.hierarchy
                        .get(bid)
                        .map_or(false, |n| n.title == b.label)
                })
        }) {
            if let (Some(target), Some(pace)) =
                (book.target_words, book.required_pace)
            {
                parts.push(format!(
                    "{} {}/{}w (pace {}w/d)",
                    book.label, book.total_words, target, pace
                ));
            }
        }
        parts.join(" · ")
    }

    // ── Bund stdlib bridge ───────────────────────────────────────────
    //
    // Called by `src/scripting/stdlib/app.rs` via `with_active_app`.
    // Each `ink_*` method exposes a single primitive operation; the
    // Bund word handler takes care of argument parsing + status
    // reporting. Methods return Option<…> for queries and Result<…>
    // for mutations so the stdlib can surface a clean error when
    // there's no open buffer / no AI session.

    pub(crate) fn ink_editor_cursor(&self) -> Option<(usize, usize)> {
        self.opened.as_ref().map(|d| d.textarea.cursor())
    }

    /// 1.2.6+ — render the story view for the named user book
    /// and write the PNG to `dest`. Same plumbing as
    /// `crate::story_view::build_story_png` (Ctrl+V W), but
    /// driven from the Bund stdlib (`ink.story.render`). Returns
    /// a human-readable error string when the book name doesn't
    /// resolve, the render fails, or the write fails.
    pub(crate) fn ink_story_render_to_path(
        &self,
        book_name: &str,
        dest: &std::path::Path,
    ) -> Result<(), String> {
        let needle = book_name.trim().to_ascii_lowercase();
        let book = self
            .hierarchy
            .flatten()
            .into_iter()
            .find_map(|(n, _)| {
                if n.kind == NodeKind::Book
                    && n.system_tag.is_none()
                    && (n.title.to_ascii_lowercase() == needle
                        || n.slug.to_ascii_lowercase() == needle)
                {
                    Some(n.clone())
                } else {
                    None
                }
            })
            .ok_or_else(|| {
                format!("no user book matches `{book_name}`")
            })?;
        let render =
            crate::story_view::build_story_png(&self.store, &self.hierarchy, book.id)
                .map_err(|e| format!("render: {e}"))?;
        std::fs::write(dest, &render.png_bytes)
            .map_err(|e| format!("write {}: {e}", dest.display()))?;
        Ok(())
    }

    pub(crate) fn ink_editor_goto(&mut self, row: usize, col: usize) -> Result<(), String> {
        let Some(doc) = self.opened.as_mut() else {
            return Err("no buffer open".into());
        };
        doc.textarea.move_cursor(tui_textarea::CursorMove::Jump(
            row as u16, col as u16,
        ));
        Ok(())
    }

    pub(crate) fn ink_editor_insert(&mut self, text: &str) -> Result<(), String> {
        let Some(doc) = self.opened.as_mut() else {
            return Err("no buffer open".into());
        };
        doc.textarea.insert_str(text);
        doc.dirty = true;
        Ok(())
    }

    pub(crate) fn ink_editor_scroll(&mut self, delta: i32) -> Result<(), String> {
        let Some(doc) = self.opened.as_mut() else {
            return Err("no buffer open".into());
        };
        let max = doc.textarea.lines().len().saturating_sub(1);
        let new = (doc.scroll_row as i64).saturating_add(delta as i64).max(0) as usize;
        doc.scroll_row = new.min(max);
        Ok(())
    }

    pub(crate) fn ink_editor_delete_line(&mut self) -> Result<(), String> {
        let Some(doc) = self.opened.as_mut() else {
            return Err("no buffer open".into());
        };
        // Kill the whole line: move to start, delete to end.
        doc.textarea.move_cursor(tui_textarea::CursorMove::Head);
        doc.textarea.delete_line_by_end();
        doc.dirty = true;
        Ok(())
    }

    pub(crate) fn ink_editor_delete_to_bol(&mut self) -> Result<(), String> {
        let Some(doc) = self.opened.as_mut() else {
            return Err("no buffer open".into());
        };
        doc.textarea.delete_line_by_head();
        doc.dirty = true;
        Ok(())
    }

    pub(crate) fn ink_editor_delete_to_eol(&mut self) -> Result<(), String> {
        let Some(doc) = self.opened.as_mut() else {
            return Err("no buffer open".into());
        };
        doc.textarea.delete_line_by_end();
        doc.dirty = true;
        Ok(())
    }

    pub(crate) fn ink_editor_text(&self) -> Option<String> {
        self.opened.as_ref().map(|d| d.textarea.lines().join("\n"))
    }

    /// First substring match in the buffer, returned as `(row, col)`.
    pub(crate) fn ink_editor_find(&self, needle: &str) -> Option<(usize, usize)> {
        if needle.is_empty() {
            return None;
        }
        let doc = self.opened.as_ref()?;
        for (row, line) in doc.textarea.lines().iter().enumerate() {
            if let Some(col) = line.find(needle) {
                return Some((row, col));
            }
        }
        None
    }

    pub(crate) fn ink_ai_clear_history(&mut self) {
        self.clear_chat_history();
    }

    pub(crate) fn ink_typst_assemble(&mut self) {
        self.schedule_assembly();
    }

    pub(crate) fn ink_typst_build(&mut self) {
        self.schedule_build();
    }

    pub(crate) fn ink_typst_take(&mut self) {
        self.schedule_take();
    }

    // ── Phase C: AI / theme / editor.replace ─────────────────────────

    /// Send a user prompt through the same AI pipeline the
    /// `Ctrl+I` chord uses: kicks off streaming inference,
    /// returns immediately. Response lands in `chat_history`
    /// once the stream completes. No synchronous result —
    /// Bund scripts that want the response read
    /// `ink.ai.history` later (e.g. in a hook firing on a
    /// subsequent action).
    /// Multi-occurrence find/replace on the open buffer. Returns
    /// the number of replacements made. Empty `find` returns 0
    /// (defends against an infinite loop with `replace.contains(find)`).
    pub(crate) fn ink_editor_replace_all(
        &mut self,
        find: &str,
        replace: &str,
    ) -> std::result::Result<i64, String> {
        if find.is_empty() {
            return Ok(0);
        }
        let doc = self
            .opened
            .as_mut()
            .ok_or_else(|| "no paragraph open".to_string())?;
        if doc.read_only {
            return Err("paragraph is read-only".into());
        }
        // Pull the body, do all replacements at once with String::replace,
        // then re-load the textarea. Avoids cursor-tracking complexity of
        // doing N find/replace passes through tui-textarea.
        let old_body = doc.textarea.lines().join("\n");
        let new_body = old_body.replace(find, replace);
        if new_body == old_body {
            return Ok(0);
        }
        // Count occurrences by counting non-overlapping matches in the
        // original body — same semantics as String::replace.
        let count = old_body.matches(find).count() as i64;
        let lines = body_to_lines(&new_body);
        let mut ta = TextArea::new(lines);
        ta.set_cursor_line_style(Style::default().add_modifier(Modifier::REVERSED));
        ta.set_line_number_style(Style::default().fg(Color::DarkGray));
        doc.textarea = ta;
        doc.dirty = true;
        doc.last_activity = std::time::Instant::now();
        Ok(count)
    }

    /// Open the `index`-th semantic-search hit for `query`.
    /// Returns true when a paragraph was loaded, false when the
    /// search came back empty or `index` is out of bounds.
    pub(crate) fn ink_search_load(
        &mut self,
        query: &str,
        index: usize,
    ) -> std::result::Result<bool, String> {
        if query.trim().is_empty() {
            return Err("empty query".into());
        }
        let raw = self
            .store
            .search_text(query, (index + 1).max(8))
            .map_err(|e| format!("search: {e}"))?;
        let hits: Vec<crate::tui::search_results::SearchHit> = raw
            .iter()
            .filter_map(crate::tui::search_results::SearchHit::parse)
            .collect();
        let Some(hit) = hits.get(index) else {
            return Ok(false);
        };
        let Some(node) = self.hierarchy.get(hit.id).cloned() else {
            return Ok(false);
        };
        if node.kind != crate::store::node::NodeKind::Paragraph {
            return Err(format!("hit `{}` is not a paragraph", node.title));
        }
        self.load_paragraph(&node)
            .map_err(|e| format!("load_paragraph: {e:#}"))?;
        Ok(true)
    }

    /// Snapshot the current AI inference state for `ink.ai.poll`.
    /// Returns `(status, response, elapsed_ms)`; status is one of
    /// `"none"` / `"streaming"` / `"done"` / `"error:<msg>"`.
    /// `response` accumulates as the stream arrives.
    pub(crate) fn ink_ai_poll(&self) -> (String, String, i64) {
        let Some(inf) = self.inference.as_ref() else {
            return ("none".into(), String::new(), 0);
        };
        let status = match &inf.status {
            InferenceStatus::Streaming => "streaming".to_string(),
            InferenceStatus::Done => "done".to_string(),
            InferenceStatus::Error(e) => format!("error: {e}"),
        };
        let elapsed = inf.started_at.elapsed().as_millis() as i64;
        (status, inf.response.clone(), elapsed)
    }

    /// Spawn an inference (same as `ink.ai.send`) and then poll
    /// until it terminates or `timeout_ms` elapses. The TUI does
    /// not redraw while the loop runs — that's the user's
    /// trade-off when picking the blocking variant. Returns the
    /// accumulated response, or `None` if the wait timed out (the
    /// inference itself keeps streaming in the background and
    /// can be read with `ink.ai.poll`).
    pub(crate) fn ink_ai_send_blocking(
        &mut self,
        prompt: &str,
        timeout_ms: i64,
    ) -> std::result::Result<Option<String>, String> {
        self.ink_ai_send(prompt)?;
        let timeout = std::time::Duration::from_millis(timeout_ms.max(0) as u64);
        let started = std::time::Instant::now();
        loop {
            // Pull whatever's accumulated in the stream channel.
            self.pump_inference();
            if let Some(inf) = self.inference.as_ref() {
                match &inf.status {
                    InferenceStatus::Done => {
                        return Ok(Some(inf.response.clone()));
                    }
                    InferenceStatus::Error(e) => {
                        return Err(format!("inference error: {e}"));
                    }
                    InferenceStatus::Streaming => {}
                }
            } else {
                // No inference state — shouldn't happen since we
                // just spawned one, but stay safe.
                return Ok(None);
            }
            if started.elapsed() >= timeout {
                return Ok(None);
            }
            std::thread::sleep(std::time::Duration::from_millis(50));
        }
    }

    pub(crate) fn ink_ai_send(&mut self, prompt: &str) -> Result<(), String> {
        if prompt.trim().is_empty() {
            return Err("empty prompt".into());
        }
        // Borrow the existing input-driven path: pre-load
        // `ai_input` then call `start_inference`. Same code
        // path the AI prompt input field uses.
        self.ai_input.clear();
        for c in prompt.chars() {
            self.ai_input.insert_char(c);
        }
        self.start_inference();
        Ok(())
    }

    /// Return the chat history as a Vec of (role, content)
    /// pairs. `role` is `"user"` or `"assistant"`. Most recent
    /// turn is last.
    pub(crate) fn ink_ai_history(&self) -> Vec<(String, String)> {
        self.chat_history
            .iter()
            .map(|t| match t {
                ChatTurn::User(s) => ("user".into(), s.clone()),
                ChatTurn::Assistant(s) => ("assistant".into(), s.clone()),
            })
            .collect()
    }

    /// Set (or clear) the script-supplied system-prompt override.
    /// Empty string clears it; otherwise the inference path
    /// consults the override before falling back to the
    /// inference-mode default. See `system_prompt_override` field.
    pub(crate) fn ink_ai_set_system_prompt(&mut self, text: &str) {
        if text.trim().is_empty() {
            self.system_prompt_override = None;
        } else {
            self.system_prompt_override = Some(text.to_string());
        }
    }

    /// Mutate one theme colour at runtime AND persist the new
    /// value to `inkhaven.hjson` so the change survives restart.
    /// `field` is the theme struct's field name
    /// (`tree_paragraph_fg`, `syntax_keyword`, etc.); `hex` is
    /// `#rrggbb` or a named colour.
    ///
    /// In-memory update happens first; if HJSON write-back fails
    /// (missing `theme:` block, disk error, etc.) the runtime
    /// colour stays applied for the rest of this session — the
    /// caller logs the persistence error but doesn't roll back.
    /// That's deliberate: a hook running mid-session shouldn't
    /// have its visual feedback yanked because the project's
    /// HJSON happens to be locked.
    pub(crate) fn ink_theme_set(
        &mut self,
        field: &str,
        hex: &str,
    ) -> Result<(), String> {
        self.theme.set_by_name(field, hex)?;
        // Persist back to inkhaven.hjson. Failure here is logged
        // at WARN; the in-memory mutation already succeeded.
        let config_path = self.layout.config_path();
        let raw = match std::fs::read_to_string(&config_path) {
            Ok(s) => s,
            Err(e) => {
                tracing::warn!(
                    target: "inkhaven::theme",
                    "theme persist: read {}: {e}",
                    config_path.display(),
                );
                return Ok(());
            }
        };
        // HJSON colour literal is a double-quoted string.
        let value_lit = format!("\"{}\"", hex);
        match set_key_in_hjson_block(&raw, "theme", field, &value_lit) {
            Ok(updated) => {
                if let Err(e) = std::fs::write(&config_path, &updated) {
                    tracing::warn!(
                        target: "inkhaven::theme",
                        "theme persist: write {}: {e}",
                        config_path.display(),
                    );
                }
            }
            Err(reason) => {
                tracing::warn!(
                    target: "inkhaven::theme",
                    "theme persist: rewrite `{field}` in `theme` block: {reason}",
                );
            }
        }
        Ok(())
    }

    /// Open the BundInput modal. The user's typed string lands
    /// on the Adam workbench via `hooks::fire(hook, [string])`
    /// when they press Enter. Esc closes without firing. The
    /// caller (the `ink.input` word handler) is responsible for
    /// ensuring `hook` names a registered lambda — otherwise
    /// `hooks::fire` silently no-ops.
    pub(crate) fn open_bund_input(&mut self, prompt: &str, hook: &str) {
        self.modal = Modal::BundInput {
            prompt: prompt.to_string(),
            input: TextInput::new(),
            hook: hook.to_string(),
        };
    }

    /// Append text to the active Bund output pane, if any.
    /// Returns `true` if the text was routed to the pane,
    /// `false` if no pane is open (caller falls back to the
    /// print buffer / status bar). When `newline` is true the
    /// text becomes its own line; otherwise it's concatenated
    /// to the last line.
    pub(crate) fn append_to_bund_pane(&mut self, text: &str, newline: bool) -> bool {
        let Modal::BundPane { lines, scroll, .. } = &mut self.modal else {
            return false;
        };
        if newline {
            for chunk in text.split('\n') {
                lines.push(chunk.to_string());
            }
        } else {
            // Split on embedded newlines so a single print of
            // "a\nb" still produces two lines, but the LAST
            // chunk stays append-to-prior.
            let mut parts = text.split('\n');
            if let Some(first) = parts.next() {
                if let Some(last) = lines.last_mut() {
                    last.push_str(first);
                } else {
                    lines.push(first.to_string());
                }
            }
            for chunk in parts {
                lines.push(chunk.to_string());
            }
        }
        // Auto-scroll to bottom so streaming output is visible
        // without manual scrolling. User can scroll back later.
        let visible = 20usize; // approximate; clamped by renderer
        if lines.len() > visible {
            *scroll = lines.len() - visible;
        }
        true
    }

    /// Open (or reuse) the Bund output pane with `title`. If a
    /// pane is already open it's replaced — same as Esc-then-
    /// open. Used by `ink.pane.show`.
    pub(crate) fn open_bund_pane(&mut self, title: &str) {
        self.modal = Modal::BundPane {
            title: title.to_string(),
            lines: Vec::new(),
            scroll: 0,
        };
    }

    /// Close the Bund pane (no-op if not open).
    pub(crate) fn close_bund_pane(&mut self) {
        if matches!(self.modal, Modal::BundPane { .. }) {
            self.modal = Modal::None;
        }
    }

    /// Clear the Bund pane's line buffer while keeping it open.
    pub(crate) fn clear_bund_pane(&mut self) -> bool {
        let Modal::BundPane { lines, scroll, .. } = &mut self.modal else {
            return false;
        };
        lines.clear();
        *scroll = 0;
        true
    }

    /// Replace the first occurrence of `find` in the open
    /// buffer with `replace`. Cursor lands at the start of the
    /// replacement. Returns `true` if a match was found and
    /// replaced; `false` if no match.
    pub(crate) fn ink_editor_replace(
        &mut self,
        find: &str,
        replace: &str,
    ) -> Result<bool, String> {
        let Some(doc) = self.opened.as_mut() else {
            return Err("no buffer open".into());
        };
        if find.is_empty() {
            return Err("find string is empty".into());
        }
        // Linear scan over lines for the first match.
        let target: Option<(usize, usize)> = doc
            .textarea
            .lines()
            .iter()
            .enumerate()
            .find_map(|(r, line)| line.find(find).map(|c| (r, c)));
        let Some((row, col)) = target else {
            return Ok(false);
        };
        doc.textarea
            .move_cursor(tui_textarea::CursorMove::Jump(row as u16, col as u16));
        // Delete `find.len()` chars; tui-textarea's delete_char
        // works one char at a time and there's no bulk delete
        // by length, so iterate.
        for _ in 0..find.chars().count() {
            doc.textarea.delete_next_char();
        }
        doc.textarea.insert_str(replace);
        doc.dirty = true;
        Ok(true)
    }
}

/// Map a source-coordinate cursor (row, col) to its visual-coordinate
/// position inside a wrapped layout. Linear in number of visual rows up to
/// the cursor, which is bounded by the source size — cheap at literary scale.
pub(super) fn find_cursor_visual(
    visual: &[super::highlight::VisualRow],
    cur_row: usize,
    cur_col: usize,
) -> (usize, usize) {
    // Locate the cursor row's last visual row so end-of-line cursor lands
    // there even when the line wraps.
    let mut chosen: Option<usize> = None;
    for (i, v) in visual.iter().enumerate() {
        if v.src_row != cur_row {
            if chosen.is_some() {
                break;
            }
            continue;
        }
        let row_end = v.src_col_start + v.width_chars;
        if cur_col < row_end {
            return (i, cur_col - v.src_col_start);
        }
        chosen = Some(i);
    }
    if let Some(i) = chosen {
        let v = &visual[i];
        return (i, cur_col.saturating_sub(v.src_col_start));
    }
    (0, 0)
}


/// Fuzzy-rank `entries` against `query`. Returns indices into
/// the original Vec, ordered by score (descending). Scoring:
///   3 — title starts with query (case-insensitive)
///   2 — title contains query as a substring
///   1 — slug path contains query as a substring
///   0 — excluded
/// Empty query keeps the original ordering and returns all
/// indices (the picker treats this as "no filter applied").
/// 1.2.5+ — case-insensitive substring filter over a list of
/// `ScriptPickerEntry`s. Used by the tag-search results modal;
/// kept simple (no scoring) because the result set already
/// belongs to one chosen tag, and the user just wants to narrow
/// further by title / slug fragment.
pub(super) fn filter_tag_results(
    entries: &[ScriptPickerEntry],
    query: &str,
) -> Vec<ScriptPickerEntry> {
    let q = query.trim().to_lowercase();
    if q.is_empty() {
        return entries.to_vec();
    }
    entries
        .iter()
        .filter(|e| {
            e.title.to_lowercase().contains(&q)
                || e.slug_path.to_lowercase().contains(&q)
        })
        .cloned()
        .collect()
}

pub(super) fn fuzzy_filter_entries(
    entries: &[ScriptPickerEntry],
    query: &str,
) -> Vec<usize> {
    let q = query.trim().to_lowercase();
    if q.is_empty() {
        return (0..entries.len()).collect();
    }
    let mut scored: Vec<(i32, usize)> = Vec::new();
    for (i, e) in entries.iter().enumerate() {
        let tl = e.title.to_lowercase();
        let sl = e.slug_path.to_lowercase();
        let score = if tl.starts_with(&q) {
            3
        } else if tl.contains(&q) {
            2
        } else if sl.contains(&q) {
            1
        } else {
            0
        };
        if score > 0 {
            scored.push((score, i));
        }
    }
    // Stable sort by descending score preserves the original
    // (slug-path-sorted) order within each tier.
    scored.sort_by(|a, b| b.0.cmp(&a.0));
    scored.into_iter().map(|(_, i)| i).collect()
}

pub(super) fn format_progress_gauge(current: i64, target: i64) -> (String, i64, Style) {
    if target <= 0 {
        return ("[░░░░]".into(), 0, Style::default());
    }
    let pct = (current.max(0) * 100 / target).clamp(0, 999);
    // 4 cells, eighths-resolution per cell would be cleaner but
    // overkill — full / medium / light glyphs are enough.
    let full_cells = (pct / 25).min(4) as usize;
    let remainder = (pct % 25) as usize;
    let mut gauge = String::with_capacity(6);
    gauge.push('[');
    for _ in 0..full_cells {
        gauge.push('█');
    }
    if full_cells < 4 {
        if remainder >= 12 {
            gauge.push('▒');
        } else if remainder > 0 {
            gauge.push('░');
        } else {
            gauge.push('░');
        }
        for _ in (full_cells + 1)..4 {
            gauge.push('░');
        }
    }
    gauge.push(']');
    let style = if pct >= 100 {
        Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
    } else if pct >= 75 {
        Style::default().fg(Color::LightGreen)
    } else if pct >= 50 {
        Style::default().fg(Color::Yellow)
    } else if pct >= 25 {
        Style::default().fg(Color::LightRed)
    } else {
        Style::default()
            .fg(Color::Red)
            .add_modifier(Modifier::DIM)
    };
    (gauge, pct, style)
}

#[cfg(test)]
mod tests_scene_break {
    use super::*;

    #[test]
    fn classic_asterisks_match() {
        assert!(is_scene_break("***"));
        assert!(is_scene_break("* * *"));
        assert!(is_scene_break("  * * *  "));
        assert!(is_scene_break("*****"));
    }

    #[test]
    fn dashes_underscores_tildes_hashes_match() {
        assert!(is_scene_break("---"));
        assert!(is_scene_break("___"));
        assert!(is_scene_break("~~~"));
        assert!(is_scene_break("###"));
        assert!(is_scene_break("- - -"));
        assert!(is_scene_break("#####"));
    }

    #[test]
    fn section_sign_alone_matches() {
        assert!(is_scene_break("§"));
        assert!(is_scene_break("  §  "));
    }

    #[test]
    fn below_threshold_does_not_match() {
        assert!(!is_scene_break("**"));
        assert!(!is_scene_break("--"));
        assert!(!is_scene_break("*"));
    }

    #[test]
    fn empty_or_whitespace_no_match() {
        assert!(!is_scene_break(""));
        assert!(!is_scene_break("   "));
        assert!(!is_scene_break("\t\t"));
    }

    #[test]
    fn mixed_chars_dont_match() {
        // Three different markers in a row — not a
        // clean break pattern.
        assert!(!is_scene_break("*-*"));
        assert!(!is_scene_break("***foo"));
        assert!(!is_scene_break("foo***"));
        assert!(!is_scene_break("***bold***"));
    }

    #[test]
    fn typst_headings_dont_match() {
        assert!(!is_scene_break("= Chapter"));
        assert!(!is_scene_break("== Section"));
        assert!(!is_scene_break("=== Subsection"));
    }

    #[test]
    fn prose_does_not_match() {
        assert!(!is_scene_break("The morning was cold."));
        assert!(!is_scene_break("She lifted her shoulders."));
    }
}

#[cfg(test)]
mod tests_audio_slug {
    use super::*;

    #[test]
    fn ascii_lowercased_with_hyphens() {
        assert_eq!(audio_filename_slug("Morning Walk"), "morning-walk");
    }

    #[test]
    fn punctuation_collapsed() {
        assert_eq!(audio_filename_slug("She Said: \"Hello!\""), "she-said-hello");
    }

    #[test]
    fn runs_collapsed_no_trailing_dash() {
        assert_eq!(audio_filename_slug("  hello   world  "), "hello-world");
    }

    #[test]
    fn cyrillic_falls_through_to_hyphen_separator() {
        // Each Cyrillic char isn't ascii_alphanumeric, so
        // the slug becomes empty + fallback.  Acceptable —
        // user edits the path before pressing Enter when
        // they want a localised filename.
        assert_eq!(audio_filename_slug("Привет мир"), "paragraph");
    }

    #[test]
    fn mixed_keeps_ascii_drops_others() {
        assert_eq!(
            audio_filename_slug("Chapter 3 — введение"),
            "chapter-3"
        );
    }

    #[test]
    fn empty_falls_back() {
        assert_eq!(audio_filename_slug(""), "paragraph");
        assert_eq!(audio_filename_slug("___"), "paragraph");
    }
}

#[cfg(test)]
mod tests_truncate {
    use super::*;

    #[test]
    fn under_cap_returns_unchanged() {
        let s = "a\nb\nc".to_string();
        assert_eq!(truncate_to_lines(s.clone(), 10), s);
    }

    #[test]
    fn empty_string_returns_empty() {
        assert_eq!(truncate_to_lines(String::new(), 10), "");
    }

    #[test]
    fn exact_cap_returns_unchanged() {
        let s = "a\nb\nc".to_string();
        assert_eq!(truncate_to_lines(s.clone(), 3), s);
    }

    #[test]
    fn over_cap_truncates_and_appends_marker() {
        // 10 lines, cap 3 → keep 2 + marker; marker mentions 8.
        let s: String =
            (1..=10).map(|i| format!("l{i}")).collect::<Vec<_>>().join("\n");
        let out = truncate_to_lines(s, 3);
        let lines: Vec<&str> = out.lines().collect();
        assert_eq!(lines.len(), 3, "must respect cap, got: {out:?}");
        assert_eq!(lines[0], "l1");
        assert_eq!(lines[1], "l2");
        assert!(
            lines[2].contains("8 more lines truncated"),
            "expected marker for 8 dropped, got {:?}",
            lines[2]
        );
    }

    #[test]
    fn cap_one_yields_just_the_marker() {
        // cap=1 → keep=0 head, marker reports all N as dropped.
        let s = "x\ny\nz".to_string();
        let out = truncate_to_lines(s, 1);
        let lines: Vec<&str> = out.lines().collect();
        assert_eq!(lines.len(), 1);
        assert!(lines[0].contains("3 more lines truncated"));
    }

    #[test]
    fn cap_zero_treated_as_one() {
        // Defensive: 0-cap shouldn't divide-by-zero or panic.
        let s = "a\nb".to_string();
        let out = truncate_to_lines(s, 0);
        assert!(out.contains("more lines truncated"));
    }
}

#[cfg(test)]
mod tests_bg_job {
    use super::*;
    use std::sync::mpsc::channel;

    #[test]
    fn drains_to_last_progress_then_done() {
        let (tx, rx) = channel();
        tx.send(BgMsg::Progress("a".into())).unwrap();
        tx.send(BgMsg::Progress("b".into())).unwrap();
        tx.send(BgMsg::Done(Ok("World: ✓".into()))).unwrap();
        let (progress, done) = drain_bg_messages(&rx);
        assert_eq!(progress.as_deref(), Some("b"), "last progress wins");
        assert!(matches!(done, Some(Ok(s)) if s == "World: ✓"));
    }

    #[test]
    fn no_messages_yet_is_pending() {
        let (_tx, rx) = channel::<BgMsg>();
        let (progress, done) = drain_bg_messages(&rx);
        assert!(progress.is_none() && done.is_none(), "still running");
    }

    #[test]
    fn dropped_sender_is_a_failure() {
        let (tx, rx) = channel::<BgMsg>();
        drop(tx); // thread panicked / ended without sending Done
        let (_, done) = drain_bg_messages(&rx);
        assert!(matches!(done, Some(Err(_))), "disconnect → failure outcome");
    }
}

#[cfg(test)]
mod tests_gauge {
    use super::*;

    #[test]
    fn gauge_zero() {
        let (g, p, _) = format_progress_gauge(0, 100);
        assert_eq!(p, 0);
        assert_eq!(g, "[░░░░]");
    }

    #[test]
    fn gauge_partial() {
        let (_, p, _) = format_progress_gauge(60, 100);
        assert_eq!(p, 60);
    }

    #[test]
    fn gauge_full() {
        let (g, p, _) = format_progress_gauge(100, 100);
        assert_eq!(p, 100);
        assert_eq!(g, "[████]");
    }

    #[test]
    fn gauge_over() {
        let (g, p, _) = format_progress_gauge(250, 100);
        assert_eq!(p, 250);
        assert_eq!(g, "[████]");
    }
}

/// Try to derive a usable title from a paragraph body. Skips Typst heading
/// lines (`= …`), comments (`// …`), and blank lines. Looks for the first
/// `.`, `!`, or `?` followed by whitespace or end-of-text. Truncates the
/// result to fit `TITLE_MAX_DISPLAY` chars (with ellipsis if cut). Returns
/// None if no usable text is found.
/// Lift the lookup term for a Ctrl+B P / Ctrl+B C inference: the
/// selection if any, otherwise the (Unicode) word under the cursor. The
/// word boundaries respect Cyrillic / CJK punctuation via
/// `unicode-segmentation`, so a selection of "Москва" works the same as
/// dropping the cursor inside it. Trailing apostrophes / quotes are
/// trimmed so "King's" doesn't pull in an extra quote.
/// Dispatch the per-line highlight call based on `content_type` —
/// typst (default) goes through the cached tree-sitter highlighter;
/// "hjson" runs the lightweight hand-rolled lexer.
pub(super) fn highlight_for_content(
    highlighter: &mut super::highlight::TypstHighlighter,
    source: &str,
    theme: &super::theme::Theme,
    content_type: Option<&str>,
) -> Vec<Vec<super::highlight::StyledRun>> {
    match content_type {
        Some("hjson") => super::hjson_highlight::highlight_hjson_lines(source, theme),
        Some("bund") => super::bund_highlight::highlight_bund_lines(source, theme),
        // 1.2.8+ — Help-book paragraphs default to markdown
        // and use the hand-rolled CommonMark-subset lexer
        // (headings, fences, emphasis, links, lists, HRs).
        Some("markdown") | Some("md") =>
            super::markdown_highlight::highlight_markdown_lines(source, theme),
        _ => highlighter.highlight_lines(source, theme),
    }
}

/// Convert a tui-textarea (row, char-col) cursor into a byte offset
/// inside `source = lines.join("\n")`. Used by the mode detector to
/// query tree-sitter at the cursor's position.
fn byte_offset_for_cursor(source: &str, row: usize, col: usize) -> usize {
    let mut byte: usize = 0;
    let mut current_row = 0;
    for line in source.split_inclusive('\n') {
        if current_row == row {
            // Find the byte offset for `col` chars into this line
            // (stop before the newline if any).
            let body = line.strip_suffix('\n').unwrap_or(line);
            return byte
                + body
                    .char_indices()
                    .nth(col)
                    .map(|(b, _)| b)
                    .unwrap_or(body.len());
        }
        byte += line.len();
        current_row += 1;
    }
    source.len()
}

/// Apply an editor-style "search match" highlight to the first
/// occurrence of `needle` (case-insensitive) inside a chat-history
/// rendered line. The matched substring gets a dark foreground on a
/// pink background (re-using `search_match_bg` / `search_current_bg`
/// from the theme); surrounding text keeps its original styling.
///
/// Only the FIRST occurrence per line is highlighted — multiple
/// matches on the same line is a UX corner case; the user can hit
/// Ctrl+X to walk to the next line's match either way.
pub(super) fn highlight_substring_in_line(
    line: &mut ratatui::text::Line<'static>,
    needle_lower: &str,
    is_current: bool,
    theme: &super::theme::Theme,
) {
    if needle_lower.is_empty() {
        return;
    }
    let full: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
    let lower = full.to_lowercase();
    let Some(byte_pos) = lower.find(needle_lower) else { return };
    let byte_end = byte_pos + needle_lower.len();
    let char_start = full[..byte_pos].chars().count();
    // Use `chars().count()` rather than slicing by needle.len() so
    // we get the count in original-cased chars (UTF-8 case-fold can
    // shift byte lengths).
    let char_end = full[..byte_end].chars().count();

    let highlight_bg = if is_current {
        theme.search_current_bg
    } else {
        theme.search_match_bg
    };
    // Dark text on the pink bg — matches the editor's find-modal
    // colour scheme so the matched word stays legible.
    let mut highlight_style = ratatui::style::Style::default()
        .bg(highlight_bg)
        .fg(theme.pane_bg);
    if is_current {
        highlight_style = highlight_style.add_modifier(ratatui::style::Modifier::BOLD);
    }

    let mut new_spans: Vec<ratatui::text::Span<'static>> = Vec::new();
    let mut cursor: usize = 0;
    for span in line.spans.drain(..) {
        let span_text = span.content.to_string();
        let span_len = span_text.chars().count();
        let span_end = cursor + span_len;
        let overlap_start = char_start.max(cursor);
        let overlap_end = char_end.min(span_end);
        if overlap_start >= overlap_end {
            new_spans.push(ratatui::text::Span::styled(span_text, span.style));
            cursor = span_end;
            continue;
        }
        if overlap_start > cursor {
            let pre: String = span_text
                .chars()
                .take(overlap_start - cursor)
                .collect();
            new_spans.push(ratatui::text::Span::styled(pre, span.style));
        }
        let match_text: String = span_text
            .chars()
            .skip(overlap_start - cursor)
            .take(overlap_end - overlap_start)
            .collect();
        new_spans.push(ratatui::text::Span::styled(match_text, highlight_style));
        if overlap_end < span_end {
            let post: String = span_text
                .chars()
                .skip(overlap_end - cursor)
                .collect();
            new_spans.push(ratatui::text::Span::styled(post, span.style));
        }
        cursor = span_end;
    }
    line.spans = new_spans;
}


/// Open-bracket → matching close pair the auto-close logic emits.
/// None for any character that isn't an opener we recognise.
fn open_pair_for(c: char) -> Option<char> {
    match c {
        '(' => Some(')'),
        '[' => Some(']'),
        '{' => Some('}'),
        '"' => Some('"'),
        '\'' => Some('\''),
        _ => None,
    }
}

fn is_close_pair_char(c: char) -> bool {
    matches!(c, ')' | ']' | '}' | '"' | '\'')
}

/// Case-insensitive substring filter over the baked-in typst function
/// table. Results are returned sorted alphabetically (the table is
/// already sorted; we just preserve order across filter steps).
pub(super) fn filter_functions(filter: &str) -> Vec<super::typst_funcs::TypstFn> {
    let needle = filter.trim().to_lowercase();
    if needle.is_empty() {
        return super::typst_funcs::all();
    }
    super::typst_funcs::all()
        .into_iter()
        .filter(|f| f.name.to_lowercase().contains(&needle))
        .collect()
}

/// Inspect the editor line up to the cursor and decide whether we're
/// inside `#image("…<cursor>…")`. Returns `None` when not, so the
/// caller can fall back to the regular `Ctrl+B P` (Places RAG) path.
///
/// Detection rule (line-local, no tree-sitter required):
///   1. Find the LAST occurrence of `#image(` on the line at-or-before
///      the cursor column.
///   2. Between the `(` and the cursor, there must be no balanced `)`
///      (the call is still open).
///   3. Between the `(` and the cursor, there must be exactly one `"`
///      (we are inside the first string literal).
///
/// Multi-line `#image(...)` calls — rare in practice — are not
/// detected. The line-local scope makes this a 50-line function and
/// the failure mode is "Ctrl+B P falls through to Places RAG" rather
/// than a bug.
fn detect_image_call_context(line: &str, cursor_col: usize) -> Option<ImageCallContext> {
    let cursor_byte = char_offset_to_byte(line, cursor_col);
    let prefix = &line[..cursor_byte];
    // Walk backward to find the last `#image(`. Allow whitespace
    // between `image` and `(` (e.g. `#image (` is uncommon but legal).
    let open_paren_idx = find_image_open(prefix)?;
    // After the `(`, count parens + quotes up to the cursor.
    let between = &prefix[open_paren_idx + 1..];
    let mut depth: i32 = 1; // we're inside the open paren
    let mut quotes: usize = 0;
    for c in between.chars() {
        match c {
            '(' => depth += 1,
            ')' => {
                depth -= 1;
                if depth <= 0 {
                    return None; // call already closed before cursor
                }
            }
            '"' => quotes += 1,
            _ => {}
        }
    }
    if quotes != 1 {
        return None;
    }
    // Look forward for a closing quote on the same line so the picker
    // can decide whether to add one.
    let suffix = &line[cursor_byte..];
    let closing_quote_present = suffix
        .chars()
        .scan(false, |escape, c| {
            if *escape {
                *escape = false;
                return Some((false, c));
            }
            if c == '\\' {
                *escape = true;
                return Some((false, c));
            }
            Some((c == '"', c))
        })
        .any(|(is_close, _)| is_close);
    Some(ImageCallContext {
        closing_quote_present,
    })
}

fn find_image_open(prefix: &str) -> Option<usize> {
    // Iterate in reverse to find the LAST `#image[whitespace]?(`.
    let bytes = prefix.as_bytes();
    let mut i = bytes.len();
    while i > 0 {
        if bytes[i - 1] == b'(' {
            // Look back for "image" with optional whitespace, and a `#`
            // somewhere before that on this scan window.
            let head = &prefix[..i - 1];
            let trimmed = head.trim_end();
            if let Some(stripped) = trimmed.strip_suffix("image") {
                if stripped.ends_with('#') {
                    return Some(i - 1);
                }
            }
        }
        i -= 1;
    }
    None
}

fn char_offset_to_byte(s: &str, char_off: usize) -> usize {
    s.char_indices()
        .nth(char_off)
        .map(|(b, _)| b)
        .unwrap_or(s.len())
}

pub(super) fn current_word_or_selection(doc: &OpenedDoc) -> String {
    if let Some(((r1, c1), (r2, c2))) = doc.textarea.selection_range() {
        return slice_lines(doc.textarea.lines(), r1, c1, r2, c2)
            .trim()
            .to_string();
    }
    let (row, col) = doc.textarea.cursor();
    let lines = doc.textarea.lines();
    let Some(line) = lines.get(row) else {
        return String::new();
    };
    use unicode_segmentation::UnicodeSegmentation;
    for (byte_off, w) in line.unicode_word_indices() {
        let start_col = line[..byte_off].chars().count();
        let end_col = start_col + w.chars().count();
        if col >= start_col && col <= end_col {
            return w.trim_matches(|c: char| c == '\'' || c == '"').to_string();
        }
    }
    String::new()
}

fn compute_book_stats(
    hierarchy: &Hierarchy,
    book: &Node,
    project_root: &Path,
) -> BookStats {
    let mut stats = BookStats::default();
    for id in hierarchy.collect_subtree(book.id) {
        let Some(node) = hierarchy.get(id) else { continue };
        match node.kind {
            NodeKind::Book => {} // the book itself — don't count it as a chapter
            NodeKind::Chapter => stats.chapters += 1,
            NodeKind::Subchapter => stats.subchapters += 1,
            NodeKind::Paragraph => {
                stats.paragraphs += 1;
                stats.words += node.word_count;
                if let Some(rel) = node.file.as_ref() {
                    if let Ok(body) =
                        std::fs::read_to_string(project_root.join(rel))
                    {
                        stats.sentences += count_sentences(&body);
                    }
                }
            }
            NodeKind::Image => stats.images += 1,
            // Bund scripts aren't book content; they don't add to
            // word / sentence counts. Tracking them in their own
            // stats slot is a follow-up.
            NodeKind::Script => {}
        }
    }
    stats
}

/// Count sentence-terminators (`. ! ?`) in prose text, ignoring Typst
/// heading lines (`= ...`) and comments (`// ...`). A run of repeated
/// terminators (e.g. `...` or `?!`) counts as one sentence. The result
/// is an estimate — Typst markup like `#image(...)` and inline math can
/// confuse it — but it's good enough for a UI read-out and consistent
/// across runs.
fn count_sentences(content: &str) -> usize {
    let mut count = 0;
    let mut in_run = false;
    for line in content.lines() {
        let t = line.trim_start();
        if t.is_empty() || t.starts_with('=') || t.starts_with("//") {
            in_run = false;
            continue;
        }
        for c in t.chars() {
            if matches!(c, '.' | '!' | '?') {
                if !in_run {
                    count += 1;
                    in_run = true;
                }
            } else {
                in_run = false;
            }
        }
        in_run = false;
    }
    count
}


/// Render one Quick reference entry as a single Line, sized to fit
/// `col_w` terminal cells. Headers get cyan-bold styling; regular entries
/// get a fixed 14-char key column followed by the description.
pub(super) fn format_entry_line(e: &quickref::Entry, col_w: usize) -> Line<'static> {
    if e.is_header {
        let text = if e.key.is_empty() {
            String::new()
        } else {
            format!(" {}", e.key)
        };
        return Line::from(Span::styled(
            truncate_to_chars(&text, col_w),
            Style::default()
                .fg(Color::Cyan)
                .add_modifier(Modifier::BOLD),
        ));
    }
    let key_field = 14;
    // Pad/truncate the key to a fixed width so descriptions align.
    let key_padded = pad_or_trim(&e.key, key_field);
    let desc_max = col_w.saturating_sub(key_field + 2);
    let desc = truncate_to_chars(&e.desc, desc_max);
    let line = format!(" {} {}", key_padded, desc);
    Line::from(vec![
        Span::styled(
            format!(" {}", key_padded),
            Style::default()
                .fg(Color::Yellow)
                .add_modifier(Modifier::BOLD),
        ),
        Span::raw(" "),
        Span::raw(desc),
    ])
    // Note: `line` above is unused — kept as a clarity comment of the
    // intended width budget. Compiler will eliminate.
    .style(Style::default())
    .alignment(ratatui::layout::Alignment::Left)
    .clone()
    .ok_or_else_unused(line)
}

/// Stub to silence the unused `line` binding above without changing
/// semantics. Compiler should inline to no-op.
trait OkOrElseUnused {
    fn ok_or_else_unused(self, _unused: String) -> Self;
}
impl OkOrElseUnused for Line<'static> {
    fn ok_or_else_unused(self, _unused: String) -> Self {
        self
    }
}


/// Standard text-input key dispatch: typing, navigation, deletion. Shared
/// helper so new modals don't have to re-implement the pattern that older
/// modals (Add, Rename, FindReplace) inline.
/// One-line status-bar summary of a `bund eval` result. Surfaces
/// captured stdout (from print / println) and the top-of-stack
/// value in a stable order. `context` is an optional preamble —
/// passed when the eval came from a named buffer ("ran `script-
/// name`").
fn format_eval_output(out: &crate::scripting::EvalOutput, context: Option<&str>) -> String {
    let stdout = out.stdout.trim_end().to_string();
    let top_str = out
        .top
        .as_ref()
        .map(crate::scripting::format_value);
    let preamble = match context {
        Some(name) => format!("bund `{name}`"),
        None => "bund".to_string(),
    };
    match (stdout.is_empty(), top_str) {
        (true, Some(v)) => format!("{preamble} → {v}"),
        (false, Some(v)) => format!("{preamble} → {v}  ·  stdout: {stdout}"),
        (false, None) => format!("{preamble} stdout: {stdout}"),
        (true, None) => format!("{preamble}: (no result)"),
    }
}

pub(super) fn handle_text_input_key(input: &mut TextInput, key: KeyEvent) {
    use KeyCode::*;
    match key.code {
        Backspace => input.backspace(),
        Delete => input.delete(),
        Left => input.move_left(),
        Right => input.move_right(),
        Home => input.move_home(),
        End => input.move_end(),
        Char(c) => {
            let mut residual = key.modifiers;
            residual.remove(KeyModifiers::SHIFT);
            if residual.is_empty() {
                let final_c = if key.modifiers.contains(KeyModifiers::SHIFT)
                    && c.is_ascii_alphabetic()
                {
                    c.to_ascii_uppercase()
                } else {
                    c
                };
                input.insert_char(final_c);
            }
        }
        _ => {}
    }
}

/// System prompt used by regular chat when InferenceMode is Local. The
/// model is told to treat any "── … context ──" blocks the user prepends
/// (via the AI scope cycle) as the sole admissible source and to refuse
/// rather than fall back on general knowledge. Empty context just means
/// the conversation itself is the source.
pub(super) const LOCAL_SYSTEM_PROMPT: &str = "\
You are Inkhaven's writing assistant. The user may prepend `── … context ──` \
blocks (a selection, a paragraph, or whole chapters from their book). When \
present, treat those blocks as the sole admissible source of information. \
Do NOT introduce facts, names, or claims that are absent from the context. \
If the context is insufficient, say so plainly rather than improvising. \
Without an explicit context block, rely only on prior conversation turns; \
do not draw on general knowledge.";

/// System prompt used by regular chat when InferenceMode is Full. Context
/// blocks (when present) are still ground truth, but the model is free to
/// augment with general knowledge — the typical chat / brainstorm mode.
pub(super) const FULL_SYSTEM_PROMPT: &str = "\
You are Inkhaven's writing assistant. If the user prepends `── … context ──` \
blocks, treat that text as ground truth and prefer it to any conflicting \
general knowledge. Otherwise, answer freely using your general knowledge \
of writing craft, world-building, and the relevant subject matter. Be \
concise; favour short paragraphs and concrete suggestions.";

/// Grammar-check system prompt. Bounds the model to a copy-editor role,
/// keeps Typst markup intact, and emits the final corrected paragraph
/// inside machine-parseable markers (`<<<CORRECTED>>>` / `<<<END>>>`) so
/// the AI pane's `T` action can lift it out without dragging the
/// summary / issue list along.
pub(super) const GRAMMAR_CHECK_SYSTEM_PROMPT: &str = "\
You are a meticulous copy-editor reviewing a paragraph from a Typst-formatted \
manuscript. Check for grammar, syntax, and punctuation issues only. \
Preserve every Typst markup token verbatim — `= Heading`, `== Subheading`, \
`*bold*`, `_italic_`, `#link(\"…\")[…]`, raw / code blocks, and any other \
Typst-specific syntax must round-trip unchanged. Do not rewrite for style, \
do not change voice, do not propose structural edits unless the original \
sentence is grammatically broken.

Output format (follow exactly):
1. Start with a short summary line (e.g. \"3 grammar issues, 1 punctuation \
issue, otherwise clean\").
2. Then list each issue with the exact original phrase and a suggested \
correction.
3. Finally, emit the fully corrected paragraph between the literal markers \
shown below — nothing else may appear inside the markers, and the markers \
themselves must appear on their own lines, byte-for-byte:

<<<CORRECTED>>>
(the corrected paragraph, with every Typst markup token preserved)
<<<END>>>

The markers are LITERAL. Do not abbreviate them to `<<>>`, `<<END>>`, \
`<<<corrected>>>`, or any other variant — the editor pipeline accepts \
those shapes as a fallback but the canonical form above keeps round-trip \
testing reliable. Do not place commentary inside the markers. The editor \
pipeline will lift the text between the markers and overwrite the \
paragraph buffer with it.";

/// Markers the grammar-check system prompt instructs the model to wrap
/// the corrected paragraph in. Kept as named constants so the parser and
/// the prompt stay in sync.
pub(super) const CORRECTED_BEGIN: &str = "<<<CORRECTED>>>";
pub(super) const CORRECTED_END: &str = "<<<END>>>";
/// 1.2.13+ Phase C.2 — markers the translation envelope
/// wraps the target-language block in.  The Insert apply
/// chord lifts only this block when `pending_translation`
/// is set, so the gloss table + applied-rules list stay
/// in the AI pane but the manuscript only gets the prose.
/// Sharing `CORRECTED_END` keeps a single sentinel — the
/// LLM is more reliable when both close markers are the
/// same token across prompt families.
pub(super) const TRANSLATION_BEGIN: &str = "<<<TRANSLATION>>>";

/// 1.2.12+ Phase B — promoted to a 5-arm language match.
/// Caller passes the ISO 639-1 code from
/// `App::active_prompt_language()`.  The English arm is
/// the canonical / reference variant; the other four are
/// 1.2.21+ — sentinel prefixing the seeded Facts user turn in the
/// chat history.  `seed_facts_session` matches on it for idempotence
/// (so reloading a persisted chat or re-cycling F9 never duplicates
/// the seed); it also reads as a clear "this is the loaded facts
/// reference" label in the AI pane.
pub(crate) const FACTS_SEED_MARKER: &str = "⟦Facts⟧ ";

/// 1.2.21+ — one-line framing prepended to the seeded Facts user turn.
pub(crate) fn facts_seed_intro(lang_iso: &str) -> &'static str {
    match lang_iso {
        "ru" => "Установленные факты мира — считай их истиной при проверке этой рукописи:",
        "de" => "Etablierte Weltfakten — behandle sie als Grundwahrheit bei der Faktenprüfung dieses Manuskripts:",
        "fr" => "Faits établis du monde — considère-les comme la vérité de référence pour vérifier ce manuscrit :",
        "es" => "Hechos establecidos del mundo — trátalos como verdad de referencia al verificar este manuscrito:",
        _ => "Established world facts — treat these as ground truth when fact-checking this manuscript:",
    }
}

/// 1.2.21+ — the seeded assistant acknowledgement that follows the
/// facts in the Facts-scope chat prologue.
pub(crate) fn facts_seed_ack(lang_iso: &str) -> &'static str {
    match lang_iso {
        "ru" => "Факты мира загружены. Попроси проверить любой фрагмент по ним или вставь текст для проверки.",
        "de" => "Weltfakten geladen. Bitte mich, eine Passage damit abzugleichen, oder füge Prosa zur Faktenprüfung ein.",
        "fr" => "Faits du monde chargés. Demande-moi de vérifier un passage par rapport à eux, ou colle un texte à vérifier.",
        "es" => "Hechos del mundo cargados. Pídeme verificar cualquier pasaje con ellos, o pega prosa para comprobar.",
        _ => "Loaded the world facts. Ask me to check any passage against them, or paste prose to fact-check.",
    }
}

/// 1.2.21+ — multilingual system prompt for the Facts scope + the
/// `Ctrl+B Shift+X` fact-check chord.  Frames the model as a
/// fact-analysis / fact-checking assistant grounded in the supplied
/// established facts.  Unknown codes fall back to English.
pub(crate) fn facts_scope_system_prompt(lang_iso: &str) -> &'static str {
    match lang_iso {
        "ru" => "Ты — внимательный помощник по проверке фактов в художественном произведении. \
В разговор включены установленные факты мира истории — климат, география, времена года, расстояния, хронология. \
Считай эти факты истиной. Когда автор спрашивает об отрывке, проверь его на противоречия с установленными фактами \
и точно назови, какой факт нарушен и как. Если отрывок согласуется — кратко скажи об этом. Не выдумывай новых фактов; \
если что-то не охвачено установленными фактами, скажи, что это не определено, а не угадывай.",
        "de" => "Du bist ein sorgfältiger Assistent für Faktenprüfung und Faktenanalyse für ein belletristisches Werk. \
Das Gespräch enthält die etablierten Fakten der Welt der Geschichte — Klima, Geografie, Jahreszeiten, Entfernungen, Chronologie. \
Behandle diese Fakten als Grundwahrheit. Wenn die Autorin nach einer Passage fragt, prüfe sie auf Widersprüche zu den \
etablierten Fakten und benenne genau, welcher Fakt wie verletzt wird. Ist eine Passage konsistent, sage es kurz. Erfinde \
keine neuen Fakten; was nicht von den etablierten Fakten abgedeckt ist, bezeichne als unbestimmt, statt zu raten.",
        "fr" => "Tu es un assistant méticuleux de vérification et d'analyse des faits pour une œuvre de fiction. \
La conversation inclut les faits établis du monde de l'histoire — climat, géographie, saisons, distances, chronologie. \
Considère ces faits comme la vérité de référence. Quand l'auteur t'interroge sur un passage, vérifie s'il contredit les \
faits établis et nomme précisément quel fait est violé et comment. Si un passage est cohérent, dis-le brièvement. \
N'invente jamais de faits ; si quelque chose n'est pas couvert par les faits établis, dis que c'est non spécifié plutôt que de deviner.",
        "es" => "Eres un asistente meticuloso de verificación y análisis de hechos para una obra de ficción. \
La conversación incluye los hechos establecidos del mundo de la historia — clima, geografía, estaciones, distancias, cronología. \
Trata esos hechos como verdad de referencia. Cuando el autor pregunte por un pasaje, compruébalo en busca de contradicciones \
con los hechos establecidos y nombra exactamente qué hecho se viola y cómo. Si un pasaje es coherente, dilo brevemente. \
No inventes hechos nuevos; si algo no está cubierto por los hechos establecidos, di que no está especificado en lugar de adivinar.",
        _ => "You are a meticulous fact-checking and facts-analysis assistant for a work of fiction. \
The conversation includes the established facts of the story's world — climate, geography, seasons, distances, chronology. \
Treat those facts as ground truth. When the author asks about a passage, check it for contradictions with the established \
facts and name exactly which fact is violated and how. If a passage is consistent, say so briefly. Never invent new facts; \
if something is not covered by the established facts, say it is unspecified rather than guessing.",
    }
}

/// 1.2.21+ — embedded fallback instruction for the `Ctrl+B Shift+X`
/// fact-check chord.  Pairs with [`facts_scope_system_prompt`] as the
/// system framing; the open paragraph + the established facts are
/// appended by the caller.  Five-language; English is the floor.
pub(crate) fn fact_check_default_prompt(lang_iso: &str) -> &'static str {
    match lang_iso {
        "ru" => "Проверь абзац ниже на соответствие установленным фактам мира из этого сообщения. \
Для КАЖДОГО утверждения в абзаце, которое ПРОТИВОРЕЧИТ установленному факту (места, расстояния, направления, климат, \
времена года, даты, длительности), выведи ОДНУ строку строго в формате:\n\
  claim | fact | detail\n\
где `claim` — точная фраза из текста, `fact` — нарушенный установленный факт, `detail` — объяснение в одну строку. \
Не переписывай абзац и не выводи ничего лишнего. Если противоречий нет — не выводи ничего.",
        "de" => "Prüfe den Absatz unten gegen die etablierten Weltfakten aus dieser Nachricht. \
Gib für JEDE Aussage im Absatz, die einem etablierten Fakt WIDERSPRICHT (Orte, Entfernungen, Richtungen, Klima, \
Jahreszeiten, Daten, Dauern), GENAU EINE Zeile im Format aus:\n\
  claim | fact | detail\n\
wobei `claim` die genaue Formulierung aus dem Text ist, `fact` der verletzte etablierte Fakt und `detail` eine einzeilige \
Erklärung. Schreibe den Absatz nicht um und gib nichts anderes aus. Gibt es keine Widersprüche, gib nichts aus.",
        "fr" => "Vérifie le paragraphe ci-dessous par rapport aux faits établis du monde fournis dans ce message. \
Pour CHAQUE affirmation du paragraphe qui CONTREDIT un fait établi (lieux, distances, directions, climat, saisons, dates, \
durées), produis UNE ligne au format exact :\n\
  claim | fact | detail\n\
où `claim` est la phrase exacte du texte, `fact` le fait établi violé, et `detail` une explication d'une ligne. \
Ne réécris pas le paragraphe et n'affiche rien d'autre. S'il n'y a aucune contradiction, n'affiche rien.",
        "es" => "Verifica el párrafo de abajo frente a los hechos establecidos del mundo incluidos en este mensaje. \
Para CADA afirmación del párrafo que CONTRADIGA un hecho establecido (lugares, distancias, direcciones, clima, estaciones, \
fechas, duraciones), produce UNA línea con el formato exacto:\n\
  claim | fact | detail\n\
donde `claim` es la frase exacta del texto, `fact` el hecho establecido violado y `detail` una explicación de una línea. \
No reescribas el párrafo ni muestres nada más. Si no hay contradicciones, no muestres nada.",
        _ => "Fact-check the paragraph below against the established world facts included in this message. \
For EACH claim in the paragraph that CONTRADICTS an established fact (places, distances, directions, climate, seasons, \
dates, durations), output ONE line in exactly this format:\n\
  claim | fact | detail\n\
where `claim` is the exact phrase from the prose, `fact` is the established fact it violates, and `detail` is a one-line \
explanation. Do not rewrite the paragraph and output nothing else. If nothing contradicts, output nothing.",
    }
}

/// translations of the same instructions tuned for native
/// grammatical idiom.  Unknown codes fall back to English
/// so the resolver always has a floor.  Same shape for the
/// other six embedded prompts below.
pub(crate) fn grammar_check_default_prompt(lang_iso: &str) -> &'static str {
    match lang_iso {
        "ru" => "Сделай корректорскую вычитку русского абзаца ниже. Проверь \
синтаксис, согласование, время и пунктуацию; отметь всё, что нарушает \
нормы современного русского языка. В тексте может быть разметка Typst — \
сохрани её дословно в исправленном варианте. После списка замечаний дай \
полностью исправленный абзац.",
        "es" => "Haz una corrección de estilo del párrafo en español que sigue. \
Revisa sintaxis, concordancia, tiempos verbales y puntuación; señala todo \
lo gramaticalmente incorrecto según la norma estándar del español. Puede \
haber marcado Typst — consérvalo literalmente en la versión corregida. \
Tras la lista de problemas, da el párrafo corregido por completo.",
        "de" => "Führe einen Korrektorat-Durchgang am folgenden deutschen Absatz \
durch. Prüfe Syntax, Kongruenz, Tempus und Interpunktion; markiere \
alles, was nach der Standard-Grammatik des Deutschen grammatisch falsch \
ist. Typst-Markup kann vorkommen — übernimm es wörtlich in den \
korrigierten Text. Liste die Probleme auf und gib danach den vollständig \
korrigierten Absatz.",
        "fr" => "Effectue une relecture de copy-edit sur le paragraphe français \
ci-dessous. Vérifie la syntaxe, l'accord, les temps et la ponctuation ; \
signale tout ce qui est grammaticalement incorrect au regard de la norme \
française standard. Du balisage Typst peut être présent — conserve-le \
tel quel dans toute version corrigée. Après la liste des problèmes, \
donne le paragraphe entièrement corrigé.",
        _ => "Run a copy-edit pass on the English paragraph below. Check \
syntax, agreement, tense, and punctuation; flag anything that's \
grammatically incorrect according to standard English grammar. Typst \
markup may be present — preserve it verbatim in any corrected output. \
After listing issues, give the fully corrected paragraph.",
    }
}

/// 1.2.6+ — embedded fallback for Ctrl+F12 explain-diagnostic.
/// 1.2.12+ Phase B — five-language variants.
pub(crate) fn explain_diagnostic_default_prompt(lang_iso: &str) -> &'static str {
    match lang_iso {
        "ru" => "Ниже показано диагностическое сообщение компилятора Typst \
вместе с фрагментом исходного кода. Объясни простым языком, что означает \
это сообщение, почему оно, скорее всего, возникло в данном контексте, и \
самое вероятное однострочное исправление. Если сообщение — ложное \
срабатывание (например, абзац ссылается на функцию, определённую в \
преамбуле книги, которая не видна изолированной компиляции), скажи об \
этом и переходи дальше. Ответ — короткий и конкретный.",
        "es" => "A continuación se muestra un diagnóstico del compilador Typst \
con el código fuente que lo rodea. Explica con palabras claras qué \
significa el diagnóstico, por qué probablemente se disparó en este \
contexto y la corrección más plausible de una sola línea. Si el \
diagnóstico es un falso positivo —por ejemplo, el párrafo usa una \
función definida en el preámbulo del libro que esta compilación aislada \
no ve—, dilo y sigue adelante. Mantén la respuesta breve y accionable.",
        "de" => "Unten siehst du eine Typst-Compiler-Meldung mitsamt umgebendem \
Quelltext. Erkläre in klarer Sprache, was die Meldung bedeutet, warum \
sie in diesem Kontext vermutlich ausgelöst wurde und welche einzeilige \
Korrektur am wahrscheinlichsten ist. Wenn es sich um einen Fehlalarm \
handelt — z. B. der Absatz nutzt eine im Buch-Präambel definierte \
Funktion, die diese isolierte Kompilierung nicht sieht — sag es und \
gehe weiter. Antwort knapp und umsetzbar halten.",
        "fr" => "Un diagnostic du compilateur Typst est présenté ci-dessous avec \
le code source qui l'entoure. Explique en français clair ce que signifie \
le diagnostic, pourquoi il s'est probablement déclenché dans ce \
contexte, et la correction d'une ligne la plus plausible. Si c'est un \
faux positif — par exemple, le paragraphe utilise une fonction définie \
dans le préambule du livre que cette compilation isolée ne voit pas — \
dis-le et passe à autre chose. Garde la réponse concise et actionnable.",
        _ => "A Typst compiler diagnostic is shown below with the surrounding \
source. Explain in plain English what the diagnostic means, why it \
likely fired in this context, and the most plausible one-line fix. If \
the diagnostic is a false positive — e.g. the paragraph references a \
function defined in the book's preamble that isn't visible to this \
isolated compile — say so and move on. Keep the answer tight and \
actionable.",
    }
}

/// 1.2.6+ — embedded fallback for F12 critique in editor mode.
/// 1.2.12+ Phase B — five-language variants.
pub(crate) fn critique_edit_default_prompt(lang_iso: &str) -> &'static str {
    match lang_iso {
        "ru" => "Прочти абзац ниже как черновик. Назови два-три самых слабых \
элемента: расплывчатые глаголы, абстрактные существительные там, где \
конкретное ударило бы сильнее, фразы, теряющие читателя, ритм, который \
сглаживается, утверждения, которые «плывут», образы, не оправдывающие \
своего места. Будь конкретен — цитируй именно тот оборот и предлагай \
более плотный вариант. НЕ переписывай весь абзац; критикуй его. Будь \
честным, но не разрушай текст.",
        "es" => "Lee el párrafo siguiente como un borrador. Señala los dos o \
tres elementos más débiles: verbos vagos, sustantivos abstractos donde \
lo concreto golpearía más fuerte, frases que pierden al lector, ritmo \
que se aplana, afirmaciones que titubean, imágenes que no se ganan su \
sitio. Sé específico: cita la frase exacta y propón una alternativa más \
apretada. NO reescribas el párrafo entero; critícalo. Sé honesto, no \
destructivo.",
        "de" => "Lies den folgenden Absatz wie einen Entwurf. Benenne die zwei \
oder drei schwächsten Stellen: vage Verben, abstrakte Substantive an \
Stellen, wo das Konkrete härter träfe, Sätze, die den Leser verlieren, \
Rhythmus, der erlahmt, Behauptungen, die wackeln, Bilder, die ihren \
Platz nicht verdienen. Sei konkret — zitiere die genaue Wendung und \
schlage eine straffere Alternative vor. Schreibe NICHT den ganzen \
Absatz neu; kritisiere ihn. Ehrlich, aber nicht zerstörerisch.",
        "fr" => "Lis le paragraphe ci-dessous comme un brouillon. Pointe les \
deux ou trois éléments les plus faibles : verbes vagues, noms abstraits \
là où le concret frapperait plus fort, phrases qui perdent le lecteur, \
rythme qui s'aplatit, affirmations qui vacillent, images qui ne \
méritent pas leur place. Sois précis — cite la formulation exacte et \
propose une alternative plus serrée. NE réécris PAS le paragraphe \
entier ; critique-le. Sois honnête sans être destructeur.",
        _ => "Read the paragraph below as a draft. Point out the weakest two \
or three elements: vague verbs, abstract nouns where the concrete would \
land harder, sentences that lose the reader, rhythm that flattens, \
claims that wobble, imagery that doesn't earn its place. Be specific — \
quote the exact phrase and propose a tighter alternative. Do NOT \
rewrite the whole paragraph; critique it. Be honest, not destructive.",
    }
}

/// 1.2.9+ — embedded fallback for the Ctrl+B Shift+T
/// AI-driven show-don't-tell scan.
/// 1.2.12+ Phase B — five-language variants.
pub(crate) fn show_dont_tell_default_prompt(lang_iso: &str) -> &'static str {
    match lang_iso {
        "ru" => "Прочти абзац ниже как черновик художественного текста и найди \
каждое место, где автор РАССКАЗЫВАЕТ, вместо того чтобы ПОКАЗЫВАТЬ. \
Рассказ = прямой ярлык эмоции или внутреннего состояния вместо того, \
чтобы дать поведению, чувственной детали или диалогу его проявить \
(«она злилась» — рассказ; «костяшки её пальцев побелели на стекле» — \
показ). По каждому случаю: (1) Процитируй точную фразу. (2) Назови, \
что именно рассказывается (эмоция / состояние). (3) Предложи один \
конкретный вариант показа — телесная деталь, чувственный штрих, \
действие или короткая реплика. Переписанный фрагмент той же длины, \
что и исходная строка, не абзац. Пропускай случаи, где рассказ \
оправдан (переходные фразы, краткое резюме, устоявшаяся внутренняя \
точка зрения в первом лице). Если абзац уже силён — скажи это прямо и \
остановись. Не разводи воду.",
        "es" => "Lee el párrafo de abajo como un borrador de ficción y busca \
cada lugar donde el autor está CONTANDO en lugar de MOSTRANDO. Contar \
= etiquetar directamente una emoción o estado interno en vez de dejar \
que la conducta, el detalle sensorial o el diálogo lo revelen («estaba \
enfadada» es contar; «los nudillos se le blanquearon contra el vaso» es \
mostrar). Para cada caso de contar: (1) Cita la frase exacta. (2) \
Nombra lo que se está contando (la emoción / el estado). (3) Propón un \
mostrar concreto — un gesto corporal, un detalle sensorial, una \
acción, o un fragmento de diálogo. La reescritura debe tener la misma \
longitud que la línea original, no un párrafo. Omite los casos donde \
contar es deliberadamente eficiente (transiciones, resumen, interioridad \
asentada en primera persona). Si el párrafo ya es fuerte, dilo \
claramente y para. No rellenes.",
        "de" => "Lies den folgenden Absatz als Belletristik-Entwurf und finde \
jede Stelle, an der der Autor BERICHTET statt ZU ZEIGEN. Berichten = \
eine Emotion oder einen inneren Zustand direkt benennen, statt Verhalten, \
Sinnesdetail oder Dialog ihn verraten zu lassen («sie war wütend» ist \
berichtet; «ihre Knöchel weißten sich um das Glas» ist gezeigt). Für \
jeden Berichts-Fall: (1) Zitiere die genaue Wendung. (2) Benenne, was \
berichtet wird (die Emotion / der Zustand). (3) Schlage eine konkrete \
Zeige-Umschreibung vor — eine Körpergeste, ein Sinnesdetail, eine \
Handlung oder ein Dialog-Fragment. Die Umschreibung soll dieselbe Länge \
haben wie die Berichts-Zeile, kein Absatz. Überspringe Fälle, in denen \
Berichten bewusst effizient ist (Überleitungen, Zusammenfassung, \
etablierte Innensicht in der ersten Person). Wenn der Absatz schon \
stark ist, sag es klar und hör auf. Kein Füller.",
        "fr" => "Lis le paragraphe ci-dessous comme un brouillon de fiction et \
repère chaque endroit où l'auteur RACONTE au lieu de MONTRER. Raconter \
= étiqueter directement une émotion ou un état interne au lieu de \
laisser le comportement, le détail sensoriel ou le dialogue le \
révéler («elle était en colère» = raconter ; «ses jointures \
blanchirent autour du verre» = montrer). Pour chaque cas de \
raconter : (1) Cite la formulation exacte. (2) Nomme ce qui est \
raconté (l'émotion / l'état). (3) Propose une réécriture-montrer \
concrète — un geste corporel, un détail sensoriel, une action, ou un \
fragment de dialogue. La réécriture doit faire la même longueur que \
la ligne d'origine, pas un paragraphe. Saute les cas où raconter est \
volontairement efficace (transitions, résumés, intériorité établie \
en première personne). Si le paragraphe est déjà solide, dis-le \
clairement et arrête. Pas de remplissage.",
        _ => "Read the paragraph below as a fiction draft and find every \
place where the writer is TELLING rather than SHOWING. Telling = \
directly labelling an emotion or internal state instead of letting \
behaviour, sensory detail, or dialogue reveal it (`she was angry` is \
telling; `her knuckles whitened around the glass` is showing). For \
each telling instance: (1) Quote the exact phrase. (2) Name what's \
being told (the emotion / state). (3) Propose one concrete show- \
rewrite — a body-language beat, a sensory detail, an action, or a \
fragment of dialogue. Keep the rewrite the same length as the telling \
line, not a paragraph. Skip cases where telling is deliberately \
efficient (transition lines, summary, established interiority in \
first-person POV). If the paragraph is already strong, say so plainly \
and stop. Don't pad.",
    }
}

/// 1.2.11+ — embedded fallback prompt for the
/// `Ctrl+B Shift+M` AI-driven sentence-rhythm rewrite.
/// 1.2.12+ Phase B — five-language variants.  Each
/// variant explicitly forbids translation in the
/// target language so the model rewrites in place
/// rather than localising mid-call.
pub(crate) fn sentence_rhythm_rewrite_default_prompt(lang_iso: &str) -> &'static str {
    match lang_iso {
        "ru" => "Перепиши русский абзац ниже так, чтобы сломать его монотонный \
синтаксический ритм. Чередуй короткие, резкие фразы с более длинными, \
чтобы у читательского уха была вариативность. Сохрани голос, тон, \
смысл, именованных персонажей, прямую речь, тире и абзацные разрывы. \
НЕ переводи; держи прозу на русском. НЕ резюмируй; переписывай строка \
за строкой.

Верни ТОЛЬКО переписанный абзац. Без вступления («Вот переписанный \
вариант:»), без комментариев, без markdown-заголовков, без списков, \
без объяснений. Чистая проза, готовая к вставке обратно в редактор.",
        "es" => "Reescribe el párrafo en español de abajo para romper su ritmo \
sintáctico monótono. Mezcla frases cortas y secas con otras más \
largas, de modo que el oído del lector tenga variedad. Conserva la voz, \
el tono, el sentido, los personajes nombrados, los diálogos entre \
comillas, las rayas y los saltos de párrafo. NO traduzcas; mantén la \
prosa en español. NO resumas; reescribe línea por línea.

Devuelve SÓLO el párrafo reescrito. Sin prefacio («Aquí está la \
reescritura:»), sin comentarios, sin encabezados markdown, sin viñetas, \
sin explicaciones. Prosa simple, lista para pegar en el editor.",
        "de" => "Schreibe den deutschen Absatz unten so um, dass sein \
monotoner Satzrhythmus aufgebrochen wird. Mische kurze, knappe Sätze \
mit längeren, sodass das Ohr des Lesers Abwechslung bekommt. Bewahre \
Stimme, Ton, Bedeutung, benannte Figuren, wörtliche Rede, Gedankenstriche \
und Absatzumbrüche. Übersetze NICHT; halte die Prosa auf Deutsch. \
Fasse NICHT zusammen; schreibe Zeile für Zeile um.

Gib NUR den umgeschriebenen Absatz zurück. Keine Einleitung (\"Hier ist \
die Umschrift:\"), keine Kommentare, keine Markdown-Überschriften, keine \
Aufzählungspunkte, keine Erklärungen. Reine Prosa, bereit zum \
Zurückeinfügen in den Editor.",
        "fr" => "Réécris le paragraphe français ci-dessous pour casser son \
rythme syntaxique monotone. Mêle des phrases courtes et nettes à \
d'autres plus longues, pour que l'oreille du lecteur ait de la \
variété. Conserve la voix, le ton, le sens, les personnages nommés, \
les dialogues entre guillemets, les tirets cadratins et les sauts de \
paragraphe. NE traduis PAS ; garde la prose en français. NE résume \
PAS ; réécris ligne à ligne.

Renvoie UNIQUEMENT le paragraphe réécrit. Pas de préface (« Voici la \
réécriture : »), pas de commentaire, pas de titres markdown, pas de \
puces, pas d'explication. Prose pure, prête à coller dans l'éditeur.",
        _ => "Rewrite the English paragraph below to break its monotonous \
sentence rhythm. Mix short, punchy sentences with longer ones so the \
reader's ear has variety to follow. Preserve voice, tone, meaning, \
named characters, quoted dialogue, em-dashes, and paragraph breaks. \
Do NOT translate; keep the prose in English. Do NOT summarise; \
rewrite line for line.

Return ONLY the rewritten paragraph. No preface (\"Here is the \
rewrite:\"), no commentary, no markdown headings, no bullet points, \
no explanation. Plain prose, ready to paste back into the editor.",
    }
}

/// 1.2.6+ — embedded fallback for F12 critique in split-edit mode.
/// 1.2.12+ Phase B — five-language variants.
pub(crate) fn critique_changes_default_prompt(lang_iso: &str) -> &'static str {
    match lang_iso {
        "ru" => "Ниже показаны две версии одного и того же абзаца: снимок \
`До` и текущий буфер `После`. Определи, что именно изменила правка \
(добавлено / удалено / переставлено), и оцени, каждое изменение — \
улучшение, регрессия или нейтрально. Цитируй именно те фразы, которые \
сдвинулись. В конце дай одно предложение, на чём должен \
сосредоточиться следующий проход правки.",
        "es" => "Abajo se muestran dos versiones del mismo párrafo: una \
instantánea `Antes` y el búfer actual `Después`. Identifica qué cambió \
la revisión (añadido / eliminado / reordenado) y evalúa si cada cambio \
es una mejora, una regresión, o neutro. Cita las frases concretas que \
se movieron. Termina con una sugerencia sobre en qué debería \
concentrarse la próxima pasada de revisión.",
        "de" => "Unten siehst du zwei Fassungen desselben Absatzes: einen \
`Vorher`-Schnappschuss und den aktuellen `Nachher`-Puffer. Stelle fest, \
was die Überarbeitung geändert hat (hinzugefügt / entfernt / umgestellt), \
und bewerte, ob jede Änderung eine Verbesserung, eine Verschlechterung \
oder neutral ist. Zitiere die konkreten Wendungen, die sich verschoben \
haben. Schließe mit einem Vorschlag dafür, worauf sich der nächste \
Überarbeitungsdurchlauf konzentrieren sollte.",
        "fr" => "Deux versions du même paragraphe sont présentées ci-dessous : \
un instantané `Avant` et le tampon courant `Après`. Identifie ce que \
la révision a changé (ajouté / retiré / réordonné), et évalue si \
chaque changement est une amélioration, une régression, ou neutre. \
Cite les formulations précises qui ont bougé. Termine par une \
suggestion sur ce qu'il faudrait viser à la prochaine passe de \
révision.",
        _ => "Two versions of the same paragraph are shown below: a `Before` \
snapshot and the current `After` buffer. Identify what the revision \
changed (added / removed / reordered), and evaluate whether each change \
is an improvement, a regression, or neutral. Quote the specific phrases \
that moved. End with one suggestion for what the next revision pass \
should focus on.",
    }
}

/// 1.2.12+ Phase D — embedded fallback for F12
/// critique when split-view is active with two
/// distinct paragraphs.  Sends both bodies and asks
/// for a comparative critique: where do they
/// converge, where do they diverge, which one lands
/// the beat better.  Particularly useful for
/// translation work (left = source, right =
/// translation: is the rendering accurate, does the
/// register match) and for draft comparison (left =
/// snapshot, right = current: which version is
/// stronger, line by line).
///
/// Five-language variants follow the same shape as
/// the other six embedded prompts; English is the
/// floor for unsupported codes.
pub(crate) fn critique_compare_default_prompt(lang_iso: &str) -> &'static str {
    match lang_iso {
        "ru" => "Ниже даны два абзаца — `Левый` и `Правый`.  Сравни их по
существу: что в них совпадает, что расходится, какой работает сильнее
и почему.  Обращай внимание на тон, ритм, точность образов, конкретику
глаголов и существительных.  Если это перевод (например, левый —
оригинал, правый — перевод), оцени, насколько перевод передаёт смысл,
голос и регистр.  Если это две черновых редакции, скажи, какая
интенсивнее и какие именно куски следующий проход правки должен
переносить из слабой в сильную.  Будь конкретен — цитируй точные
фразы.",
        "es" => "A continuación se muestran dos párrafos — `Izquierda` y
`Derecha`.  Compáralos en lo esencial: qué coincide, qué diverge, cuál
funciona mejor y por qué.  Presta atención al tono, al ritmo, a la
precisión de las imágenes, a la concreción de verbos y sustantivos.
Si se trata de una traducción (por ejemplo, izquierda = original,
derecha = traducción), evalúa si la traducción transmite el sentido,
la voz y el registro.  Si son dos borradores de revisión, indica
cuál tiene más fuerza y qué fragmentos concretos debería trasladar
la próxima pasada del débil al fuerte.  Sé específico — cita las
frases exactas.",
        "de" => "Unten siehst du zwei Absätze — `Links` und `Rechts`.
Vergleiche sie inhaltlich: was stimmt überein, was unterscheidet sich,
welcher trifft den Beat stärker und warum.  Achte auf Ton, Rhythmus,
Präzision der Bilder, Konkretheit der Verben und Substantive.  Wenn
es sich um eine Übersetzung handelt (z. B. links = Original, rechts =
Übersetzung), bewerte, ob die Übersetzung Sinn, Stimme und Register
trägt.  Wenn es zwei Entwürfe sind, sag, welcher kräftiger ist und
welche konkreten Stellen der nächste Überarbeitungsdurchlauf von der
schwächeren in die stärkere übernehmen sollte.  Sei konkret —
zitiere die genauen Wendungen.",
        "fr" => "Voici deux paragraphes — `Gauche` et `Droite`.  Compare-les
sur le fond : ce qui converge, ce qui diverge, lequel fonctionne plus
fort et pourquoi.  Sois attentif au ton, au rythme, à la précision
des images, au concret des verbes et des substantifs.  S'il s'agit
d'une traduction (par exemple, gauche = original, droite =
traduction), évalue si la traduction porte le sens, la voix et le
registre.  S'il s'agit de deux brouillons, dis lequel est plus
intense et quels fragments précis la prochaine passe de révision
doit reprendre du faible vers le fort.  Sois précis — cite les
formulations exactes.",
        _ => "Two paragraphs are shown below — `Left` and `Right`.  Compare \
them substantively: what overlaps, what diverges, which lands the \
beat harder and why.  Pay attention to tone, rhythm, precision of \
imagery, concreteness of verbs and nouns.  If this is a translation \
(e.g. left = source, right = translation), evaluate whether the \
translation carries the meaning, voice, and register.  If these are \
two drafts, say which one is stronger and which specific lines the \
next revision pass should carry from the weaker into the stronger.  \
Be specific — quote the exact phrases.",
    }
}

/// 1.2.6+ — embedded fallback for the timeline health
/// check (y / Y / Ctrl+Y inside Ctrl+V t).
/// 1.2.12+ Phase B — five-language variants.
pub(crate) fn timeline_health_default_prompt(lang_iso: &str) -> &'static str {
    match lang_iso {
        "ru" => "Ты проверяешь сюжетную хронологию ниже на внутреннюю \
согласованность. Относись к событиям как к фактам единого \
вымышленного мира; не выдумывай недостающие детали. Прочти контрольный \
список в конце и ответь по нему — будь конкретен, цитируй заголовки \
событий, предлагай конкретные исправления. Если хронология внутренне \
согласована — скажи об этом коротко, не разводя оговорок.",
        "es" => "Estás revisando la cronología narrativa que sigue para \
detectar incoherencias internas. Trata los eventos como hechos de un \
único mundo ficticio; no inventes detalles que falten. Lee la lista de \
verificación al final y responde a ella — sé específico, cita los \
títulos de eventos, y propón correcciones concretas. Si la cronología \
es internamente coherente, dilo brevemente en vez de rellenar con \
salvedades.",
        "de" => "Du prüfst die folgende Story-Zeitleiste auf innere \
Stimmigkeit. Behandle die Ereignisse als Fakten einer einzigen \
fiktionalen Welt; erfinde keine fehlenden Details. Lies die Prüfliste \
am Ende und reagiere darauf — sei konkret, zitiere Ereignistitel, und \
zeige greifbare Korrekturen auf. Wenn die Zeitleiste in sich \
stimmig ist, sag es kurz, statt mit Einschränkungen aufzufüllen.",
        "fr" => "Tu examines la chronologie narrative qui suit pour vérifier \
sa cohérence interne. Traite les événements comme des faits d'un \
unique monde fictif ; n'invente pas de détails manquants. Lis la \
liste de contrôle à la fin et réponds-y — sois précis, cite les \
titres d'événements, et propose des corrections concrètes. Si la \
chronologie est cohérente, dis-le brièvement plutôt que de remplir \
avec des réserves.",
        _ => "You are reviewing the story timeline that follows for internal \
consistency. Treat the events as facts about a single fictional world; \
do not invent missing detail. Read the audit checklist at the bottom \
and respond to it — be specific, quote event titles, and surface \
concrete fixes. If the timeline is internally coherent, say so briefly \
rather than padding with caveats.",
    }
}

/// System prompt for the F1 / "Help!" RAG flow. We force the model to
/// stick to the supplied excerpts so the help feature behaves like a
/// retrieval-grounded manual and not a general LLM chat — it should admit
/// ignorance rather than confabulate Inkhaven features.
pub(super) const HELP_SYSTEM_PROMPT: &str = "\
You are the Inkhaven help-manual assistant. Your job is to answer the \
user's question about Inkhaven (a Rust TUI literary editor for Typst \
books) using ONLY the Help excerpts the user provides below.

Rules:
- Use only the supplied excerpts. Do not invent commands, keybindings, \
  features, or file paths that are not present in the excerpts.
- Do not fall back on general LLM knowledge or assumptions about other \
  editors. If the excerpts do not answer the question, say so plainly \
  and suggest which area of the Help book might cover it.
- Quote keybindings, command names, and option labels verbatim from the \
  excerpts where useful.
- Be concise. Prefer short paragraphs and bulleted lists. Skip pleasantries.
- If multiple excerpts cover the topic, synthesise them; do not list them \
  as separate answers.
- Plain text only — no markdown headings beyond `#`/`-` lists.";

/// Inclusive on the top-left, exclusive on the bottom-right — matches
/// ratatui's Rect semantics where width/height are spans (the column at
/// `x + width` is one past the rect's last column).
fn rect_contains(rect: Rect, col: u16, row: u16) -> bool {
    if rect.width == 0 || rect.height == 0 {
        return false;
    }
    col >= rect.x
        && col < rect.x.saturating_add(rect.width)
        && row >= rect.y
        && row < rect.y.saturating_add(rect.height)
}

pub(super) fn digit_count(n: usize) -> usize {
    let mut x = n.max(1);
    let mut d = 0;
    while x > 0 {
        d += 1;
        x /= 10;
    }
    d
}

/// Allowlist of keystrokes that are non-mutating in the editor pane. Used to
/// gate the editor when an open paragraph lives inside the read-only Help
/// subtree. Anything not listed here is rejected with a status message.
fn is_read_only_safe_key(key: &KeyEvent) -> bool {
    use KeyCode::*;
    // Pure navigation / scrolling / selection — always safe.
    if matches!(
        key.code,
        Left | Right | Up | Down | Home | End | PageUp | PageDown | Esc | Tab | BackTab,
    ) {
        return true;
    }
    // F-keys: F3/F4/F6 are viewers (file picker, split toggle, snapshot
    // picker) — picking a file or snapshot WOULD mutate, but we gate the
    // actual replace in those flows. F5 (new snapshot) and Ctrl+F4 (accept
    // snapshot) mutate, so they're blocked here.
    if matches!(key.code, F(1) | F(3) | F(4) | F(6)) {
        // F4 with Ctrl is "accept snapshot" → block.
        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
        return !(matches!(key.code, F(4)) && ctrl);
    }
    // Alt+arrows and Alt+C are block-selection / block-copy — safe.
    if key.modifiers.contains(KeyModifiers::ALT) {
        if matches!(key.code, Left | Right | Up | Down) {
            return true;
        }
        if matches!(key.code, Char('c') | Char('C')) {
            return true;
        }
        return false;
    }
    // Ctrl combos: an explicit allowlist of read-safe operations.
    if key.modifiers.contains(KeyModifiers::CONTROL) {
        return matches!(
            key.code,
            Char('a') | Char('A')                // select all
            | Char('c') | Char('C')              // copy
            | Char('f') | Char('F')              // find
            | Char('x') | Char('X')              // repeat (next match)
            | Char('s') | Char('S')              // save (no-op + status)
            | Char('h') | Char('H')              // split-scroll up (only effective in split)
            | Char('j') | Char('J')              // split-scroll down
            | Char('q') | Char('Q')              // quit
            | Char('b') | Char('B')              // meta prefix
            | Char('t') | Char('T')              // focus Tree alias
            | Char('1') | Char('2') | Char('3') | Char('4') | Char('5') // focus jumps
            | Char('@') | Char('/')              // Ctrl+2 alternates / search focus
        );
    }
    false
}

pub(super) fn reverse_chip(fg: Color) -> Style {
    Style::default()
        .bg(fg)
        .fg(Color::Black)
        .add_modifier(Modifier::BOLD)
}

/// Extract the substring spanning `(start_row, start_col)..(end_row, end_col)`
/// from a slice of lines, where both ends are char-indexed. Used for pulling
/// the editor selection out for `{{selection}}` substitution.
fn slice_lines(lines: &[String], r1: usize, c1: usize, r2: usize, c2: usize) -> String {
    if r1 >= lines.len() {
        return String::new();
    }
    if r1 == r2 {
        let chars: Vec<char> = lines[r1].chars().collect();
        let s = c1.min(chars.len());
        let e = c2.min(chars.len());
        return chars[s..e].iter().collect();
    }
    let mut out = String::new();
    let first: String = lines[r1].chars().skip(c1).collect();
    out.push_str(&first);
    out.push('\n');
    for r in (r1 + 1)..r2 {
        if let Some(line) = lines.get(r) {
            out.push_str(line);
            out.push('\n');
        }
    }
    if let Some(last_line) = lines.get(r2) {
        let chars: Vec<char> = last_line.chars().collect();
        let e = c2.min(chars.len());
        out.extend(chars[..e].iter());
    }
    out
}

#[cfg(test)]
mod tests_node_lang {
    use super::*;
    use crate::store::node::{Node, NodeKind};
    use chrono::Utc;
    use uuid::Uuid;

    fn paragraph_with_tags(tags: Vec<String>) -> Node {
        Node {
            id: Uuid::new_v4(),
            kind: NodeKind::Paragraph,
            title: "test".into(),
            slug: "test".into(),
            path: Vec::new(),
            parent_id: None,
            order: 0,
            file: None,
            word_count: 0,
            modified_at: Utc::now(),
            protected: false,
            system_tag: None,
            image_ext: None,
            image_caption: None,
            image_alt: None,
            content_type: None,
            status: None,
            target_words: None,
            target_hit_at_status: None,
            linked_paragraphs: Vec::new(),
            bookmark: false,
            tags,
            ai_memory: Vec::new(),
            event: None,
        }
    }

    #[test]
    fn node_lang_tag_extracts_code() {
        let n = paragraph_with_tags(vec!["lang:ru".into(), "draft".into()]);
        assert_eq!(node_lang_tag(&n), Some("ru".into()));
    }

    #[test]
    fn node_lang_tag_case_insensitive() {
        // Tag preserves case on save, but the lookup
        // lowercases both sides.
        let n = paragraph_with_tags(vec!["LANG:RU".into()]);
        assert_eq!(node_lang_tag(&n), Some("ru".into()));
    }

    #[test]
    fn node_lang_tag_absent_returns_none() {
        let n = paragraph_with_tags(vec!["draft".into(), "wip".into()]);
        assert_eq!(node_lang_tag(&n), None);
    }

    #[test]
    fn node_lang_tag_ignores_empty_value() {
        // `lang:` with no code → not a meaningful tag; treat
        // as untagged so the resolver falls through to Pass 2.
        let n = paragraph_with_tags(vec!["lang:".into()]);
        assert_eq!(node_lang_tag(&n), None);
    }

    #[test]
    fn node_lang_matches_strict_same_language() {
        let n = paragraph_with_tags(vec!["lang:ru".into()]);
        assert!(node_lang_matches(&n, Some("ru")));
        assert!(!node_lang_matches(&n, Some("en")));
    }

    #[test]
    fn node_lang_matches_untagged_pass_skips_tagged_nodes() {
        // Pass 2 (lang_filter = None) must only match nodes
        // that have NO lang:* tag.  A node tagged `lang:ru`
        // is a Pass 1 / Pass 3 hit, never Pass 2.
        let n = paragraph_with_tags(vec!["lang:ru".into()]);
        assert!(!node_lang_matches(&n, None));
    }

    #[test]
    fn node_lang_matches_untagged_pass_matches_untagged_nodes() {
        let n = paragraph_with_tags(vec!["draft".into()]);
        assert!(node_lang_matches(&n, None));
    }
}

#[cfg(test)]
mod tests_split_view {
    use super::*;

    /// 1.2.12+ Phase A — the binding-table audit.
    /// Locks the guarantee that F4 and Ctrl+F4 keep
    /// their existing meanings.  If a future commit
    /// rebinds either, this test fails loud.
    #[test]
    fn existing_f4_and_ctrl_f4_bindings_remain_untouched() {
        use crate::tui::keybind;
        let table = keybind::KeyBindings::defaults();
        // F4 plain → ToggleSplit in editor scope.
        let f4 = table
            .top_level
            .iter()
            .find(|e| {
                e.chord.code == crossterm::event::KeyCode::F(4)
                    && e.chord.modifiers == crossterm::event::KeyModifiers::NONE
            })
            .expect("F4 must still bind to something");
        assert!(
            matches!(f4.action, keybind::Action::ToggleSplit),
            "F4 must still mean ToggleSplit; Phase A may not rebind it",
        );
        // Ctrl+F4 → AcceptSplitSnapshot.
        let ctrl_f4 = table
            .top_level
            .iter()
            .find(|e| {
                e.chord.code == crossterm::event::KeyCode::F(4)
                    && e.chord.modifiers == crossterm::event::KeyModifiers::CONTROL
            })
            .expect("Ctrl+F4 must still bind to something");
        assert!(
            matches!(ctrl_f4.action, keybind::Action::AcceptSplitSnapshot),
            "Ctrl+F4 must still mean AcceptSplitSnapshot",
        );
        // Shift+F4 → ToggleSplitView (the new chord).
        let shift_f4 = table
            .top_level
            .iter()
            .find(|e| {
                e.chord.code == crossterm::event::KeyCode::F(4)
                    && e.chord.modifiers == crossterm::event::KeyModifiers::SHIFT
            })
            .expect("Shift+F4 must bind to ToggleSplitView");
        assert!(
            matches!(shift_f4.action, keybind::Action::ToggleSplitView),
            "Shift+F4 must map to ToggleSplitView",
        );
    }

    /// 1.2.12+ Phase A — `PaneTarget::default()` is
    /// `Primary`.  Locks the contract that picker
    /// callers default to the primary slot when no
    /// explicit target is set.
    #[test]
    fn pane_target_defaults_to_primary() {
        assert_eq!(PaneTarget::default(), PaneTarget::Primary);
    }

    /// 1.2.12+ Phase B — Shift+Enter chord is
    /// detected from the SHIFT modifier on a KeyEvent.
    /// The picker handlers extract this *before*
    /// destructuring the modal so the routing
    /// decision survives the modal re-borrow.
    /// Locks the contract that the SHIFT modifier
    /// flag is what drives pin_to_secondary, not
    /// any per-picker custom chord.
    #[test]
    fn shift_modifier_drives_pin_to_secondary() {
        use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
        let plain = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
        let shifted = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT);
        assert!(!plain.modifiers.contains(KeyModifiers::SHIFT));
        assert!(shifted.modifiers.contains(KeyModifiers::SHIFT));
    }

    /// 1.2.12+ Phase C — locks the Tab dispatch
    /// contract.  The Tab handler at
    /// `app.rs:3091` flips `secondary_focused`
    /// when the secondary slot is populated; this
    /// test exercises the boolean toggle itself in
    /// isolation so a future refactor that changes
    /// the Tab dispatch can't silently break the
    /// invariant.
    #[test]
    fn tab_swap_toggles_secondary_focused_flag() {
        // The contract: starting from
        // `secondary_focused = false`, one toggle
        // flips to true; a second toggle flips
        // back.  The Tab handler in `handle_key`
        // expresses this as
        // `secondary_focused = !secondary_focused`.
        let mut focused = false;
        focused = !focused;
        assert!(focused, "first Tab swaps to right editor");
        focused = !focused;
        assert!(!focused, "second Tab swaps back to left editor");
    }

    /// 1.2.12+ Phase C — the autosave loop walks
    /// both `opened` and `secondary` slots; this
    /// already shipped in 1.2.4 with similar-mode
    /// (`tick_autosave` at app.rs:2161-2200).
    /// Phase A inherits that behaviour for split-
    /// view since both layouts use the same
    /// secondary slot.  This test guards against a
    /// future refactor that drops the secondary
    /// branch of the autosave by asserting the
    /// surface continues to expose `save_doc` as
    /// the shared save path.
    #[test]
    fn save_doc_is_callable_against_secondary_slot() {
        // Compile-time check: `save_doc` exists with
        // the right signature.  Anyone removing it
        // would break this assertion at compile
        // time, surfacing the autosave regression
        // before the binary ships.
        fn _type_check_save_doc<T>(_: T)
        where
            T: for<'a> Fn(&'a mut App, &'a mut OpenedDoc) -> Result<(), String>,
        {
        }
        _type_check_save_doc(|app: &mut App, doc: &mut OpenedDoc| {
            app.save_doc(doc)
        });
    }

    /// 1.2.12+ Phase D — locks the contract that the
    /// new `critique-compare` embedded prompt has a
    /// five-language match (the same shape as every
    /// other embedded prompt in 1.2.11+).
    #[test]
    fn critique_compare_default_prompt_covers_five_languages() {
        // Each language returns a distinct, non-empty
        // body.  English is the fallback for unknown
        // codes, so an unsupported code maps to the
        // same body as "en".
        let en = super::critique_compare_default_prompt("en");
        let ru = super::critique_compare_default_prompt("ru");
        let es = super::critique_compare_default_prompt("es");
        let de = super::critique_compare_default_prompt("de");
        let fr = super::critique_compare_default_prompt("fr");
        let unknown = super::critique_compare_default_prompt("klingon");
        // Every variant has content.
        for body in [en, ru, es, de, fr] {
            assert!(
                body.trim().len() > 80,
                "critique_compare body suspiciously short: {body:?}",
            );
        }
        // The five known variants are pairwise distinct
        // (translation, not pass-through).
        let bodies = [en, ru, es, de, fr];
        for i in 0..bodies.len() {
            for j in (i + 1)..bodies.len() {
                assert_ne!(
                    bodies[i], bodies[j],
                    "critique_compare bodies at indices {i} and {j} are identical — translation missed?",
                );
            }
        }
        // Unknown codes fall back to English.
        assert_eq!(unknown, en);
    }

    /// 1.2.21+ — the Facts-scope system prompt (and the seed
    /// intro / ack) follow the same five-language contract as
    /// every other embedded prompt.
    #[test]
    fn facts_scope_prompts_cover_five_languages() {
        for f in [
            super::facts_scope_system_prompt
                as fn(&str) -> &'static str,
            super::facts_seed_intro,
            super::facts_seed_ack,
            super::fact_check_default_prompt,
        ] {
            let en = f("en");
            let variants = [f("en"), f("ru"), f("de"), f("fr"), f("es")];
            for body in variants {
                assert!(!body.trim().is_empty(), "empty facts prompt body");
            }
            for i in 0..variants.len() {
                for j in (i + 1)..variants.len() {
                    assert_ne!(
                        variants[i], variants[j],
                        "facts prompt variants {i}/{j} identical — translation missed?",
                    );
                }
            }
            // Unknown codes fall back to English.
            assert_eq!(f("klingon"), en);
        }
        // The system prompt names the fact-check job explicitly.
        let sys = super::facts_scope_system_prompt("en").to_lowercase();
        assert!(sys.contains("fact"));
        assert!(sys.contains("ground truth"));
    }
}
