Skip to main content

kimun_notes/app/
mod.rs

1//! The **App loop** and the state it runs over (see CONTEXT.md § App shell).
2//!
3//! Submodules are internal seams: `events` (the **Input source**), `terminal`
4//! (the **Terminal session**), `bootstrap` (logging and the panic hook),
5//! `ctrl_h` (which of two keys a `0x08` byte is).
6
7pub(crate) mod bootstrap;
8pub mod ctrl_h;
9pub mod events;
10pub(crate) mod terminal;
11
12use std::io;
13use std::process::ExitCode;
14use std::sync::{Arc, RwLock};
15
16use color_eyre::eyre;
17use kimun_core::{NoteVault, VaultConfig};
18use ratatui::Terminal;
19use ratatui::prelude::Backend;
20
21use crate::app_screen::browse::BrowseScreen;
22use crate::app_screen::editor::EditorScreen;
23use crate::app_screen::onboarding::OnboardingScreen;
24use crate::app_screen::preferences::PreferencesScreen;
25use crate::app_screen::start::StartScreen;
26use crate::app_screen::{AppScreen, ScreenKind};
27use crate::components::events::{
28    AppEvent, AppTx, AppTxExt, FileOp, InputEvent, ScreenEvent, UpdateFlow,
29};
30use crate::keys::action_shortcuts::ActionShortcuts;
31use crate::keys::key_event_to_combo;
32use crate::settings::{AppSettings, SharedSettings};
33use clap::Parser;
34use color_eyre::Result;
35use events::EventHandler;
36use std::path::PathBuf;
37
38#[derive(Parser)]
39#[command(name = "kimun", about = "Kimün notes", version)]
40pub struct Cli {
41    /// Path to a custom config file
42    #[arg(long, value_name = "FILE")]
43    pub config: Option<PathBuf>,
44
45    #[command(subcommand)]
46    pub command: Option<crate::cli::CliCommand>,
47}
48
49/// The process entry the binary delegates to. Owns the runtime: the nvim
50/// backend uses `tokio::task::block_in_place` during construction, which
51/// requires the multi-thread flavor — that constraint lives here, next to the
52/// code that has it, not in the shim.
53pub fn main() -> Result<ExitCode> {
54    color_eyre::install()?;
55    let runtime = tokio::runtime::Builder::new_multi_thread()
56        .enable_all()
57        .build()?;
58    runtime.block_on(entry())
59}
60
61async fn entry() -> Result<ExitCode> {
62    // Computed once, reused by logging and the panic hook. The guard is held
63    // to the end of this function, and every exit path returns through here,
64    // so the log is always flushed.
65    let log_dir: PathBuf = kimun_core::system::log_dir().into_path_buf();
66    let _guard = bootstrap::init_logging(&log_dir);
67    bootstrap::install_panic_hook(log_dir.join("kimun.log"));
68
69    let cli = Cli::parse();
70    match cli.command {
71        Some(command) => run_cli_command(command, cli.config).await,
72        None => {
73            run_tui(cli.config).await?;
74            Ok(ExitCode::SUCCESS)
75        }
76    }
77}
78
79/// A user error (missing/existing note, bad input) prints a clean message and
80/// exits with code 2 — distinct from an internal failure, which keeps the full
81/// color_eyre report (exit 1). The recoverable/internal split is core's
82/// `VaultError::user_message`; the boundary lives here so every CLI command
83/// propagates the typed `VaultError` (via `?`) and renders identically.
84async fn run_cli_command(
85    command: crate::cli::CliCommand,
86    config: Option<PathBuf>,
87) -> Result<ExitCode> {
88    match crate::cli::run_cli(command, config).await {
89        Ok(()) => Ok(ExitCode::SUCCESS),
90        Err(report) => {
91            if let Some(msg) = report
92                .downcast_ref::<kimun_core::error::VaultError>()
93                .and_then(|ve| ve.user_message())
94            {
95                // Returned, not `process::exit`ed: the code unwinds back
96                // through `entry`, so the runtime and the log guard are
97                // dropped normally on the way out.
98                eprintln!("Error: {msg}");
99                return Ok(ExitCode::from(2));
100            }
101            Err(report)
102        }
103    }
104}
105
106/// The TUI: settings and vault first (a startup error prints to a normal
107/// terminal), then the **Terminal session**, the background tasks, the loop.
108/// The session is left before anything else is printed.
109pub async fn run_tui(config_path: Option<PathBuf>) -> Result<()> {
110    let mut app = App::new(config_path).await?;
111    // Read after App::new since both live in its settings (ADR-0015).
112    let (mouse_capture, ctrl_h_setting) = {
113        let s = app.settings.read().unwrap();
114        (s.mouse(), s.ctrl_h)
115    };
116    let mut session = terminal::TerminalSession::enter(mouse_capture)?;
117    // Resolved after `enter`: whether the kitty flags went out is half of the
118    // answer, and only the live session knows it.
119    let ctrl_h = ctrl_h::CtrlHPolicy::resolve(ctrl_h_setting, session.keyboard_enhanced());
120    let mut events = EventHandler::new(ctrl_h);
121    app.parked_flashes.extend(unreachable_binding_notice(
122        &app,
123        &session,
124        ctrl_h_setting,
125        ctrl_h,
126    ));
127
128    spawn_update_check(&app, events.app_sender());
129    respawn_rag(&mut app, &events.app_sender());
130
131    let outcome = run_app(session.terminal_mut(), &mut app, &mut events).await;
132    drop(session);
133
134    if let Err(e) = outcome {
135        tracing::error!("fatal error: {e}");
136        return Err(e.into());
137    }
138    crate::components::text_editor::widener_metrics::dump_if_enabled();
139    Ok(())
140}
141
142pub struct App {
143    /// The live **Screen**. There is always exactly one: the app starts on
144    /// **Start** and every transition swaps the box whole in `switch_screen`,
145    /// so there is no in-between state to represent.
146    pub current_screen: Box<dyn AppScreen>,
147
148    pub settings: SharedSettings,
149
150    /// The active vault. `None` until a workspace path is configured.
151    /// Rebuilt only when the workspace path changes in settings.
152    pub vault: Option<Arc<NoteVault>>,
153
154    /// Monotonic counter bumped by every screen swap (see `switch_screen`
155    /// below). The main event loop breaks its inner drain when this changes,
156    /// so the new screen is drawn before any event still queued is delivered
157    /// to it. The queued events are not dropped — filtering stale results is
158    /// the job of the addressed event families (`OverlayData`, `Ask`) and
159    /// per-event guards, not of this counter.
160    pub screen_generation: u64,
161
162    /// A newer release found by the background update check at startup, if any.
163    /// Seeded into each editor screen so the footer can show the indicator.
164    pub update: Option<crate::update::UpdateStatus>,
165
166    /// One-shot flashes waiting for a screen that can show them, oldest
167    /// first: the notice that some key binding cannot reach this terminal,
168    /// and whatever [`AppEvent::ParkFlash`] sent (Start's "your last note is
169    /// gone").
170    ///
171    /// Parked rather than sent, because they are decided while **Start** is
172    /// the live screen, and `FlashMessage` is only handled by the editor.
173    /// `switch_screen` drains them the first time it opens an editor, so each
174    /// is shown once and not re-flashed on every later screen swap (unlike
175    /// `update`, which is a standing indicator and is re-seeded).
176    pub parked_flashes: Vec<String>,
177
178    /// The background RAG sync task for the current vault, when a server is
179    /// configured. Aborted and respawned when the vault is rebuilt.
180    pub rag_sync_task: Option<tokio::task::JoinHandle<()>>,
181
182    /// Latest RAG status from the background task, held app-globally so a
183    /// freshly-opened editor can be seeded immediately (like `update`) instead
184    /// of showing nothing until the next sync tick.
185    pub rag_status: crate::rag::RagStatus,
186}
187
188impl App {
189    /// Load settings from `config_path` (or the default location) and build
190    /// the app on them.
191    pub async fn new(config_path: Option<std::path::PathBuf>) -> eyre::Result<Self> {
192        let loaded_settings = match config_path {
193            Some(path) => AppSettings::load_from_file(path)?,
194            None => AppSettings::load_from_disk()?,
195        };
196        Ok(Self::from_settings(Arc::new(RwLock::new(loaded_settings))).await)
197    }
198
199    /// The app over already-loaded settings: opens the vault those settings
200    /// name (if any) and starts on the **Start** screen. The door tests use —
201    /// nothing here touches the config file.
202    pub async fn from_settings(settings: SharedSettings) -> Self {
203        let vault = Self::open_vault(&settings).await;
204        Self {
205            current_screen: Box::new(StartScreen::new(settings.clone(), vault.clone())),
206            settings,
207            vault,
208            screen_generation: 0,
209            update: None,
210            parked_flashes: Vec::new(),
211            rag_sync_task: None,
212            rag_status: crate::rag::RagStatus::Disabled,
213        }
214    }
215
216    /// A fresh `NoteVault` for whatever workspace the settings currently
217    /// resolve to, wired to the configured index and the workspace's inbox.
218    /// `None` if no workspace is configured or the vault fails to open.
219    ///
220    /// Used at startup and every time the workspace changes (preferences
221    /// saved, onboarding finished, workspace switched).
222    pub async fn open_vault(settings: &SharedSettings) -> Option<Arc<NoteVault>> {
223        let (workspace_path, cache_path, inbox_path) = {
224            let s = settings.read().unwrap();
225            let wp = s.resolve_workspace_path();
226            let name = s.current_workspace_name();
227            let cache = name.as_ref().map(|n| s.index_for(n));
228            let ip = s
229                .workspace_config
230                .as_ref()
231                .and_then(|wc| wc.get_current_workspace())
232                .map(|e| e.effective_inbox_path());
233            (wp, cache, ip)
234        };
235        let workspace = workspace_path?;
236        let mut config = VaultConfig::new(workspace.clone());
237        if let Some(cp) = cache_path {
238            config = config.with_index(cp);
239        }
240        match NoteVault::new(config).await {
241            Ok(mut v) => {
242                if let Some(ref ip) = inbox_path {
243                    v.set_inbox_path(kimun_core::nfs::VaultPath::new(ip));
244                }
245                Some(Arc::new(v))
246            }
247            // Don't swallow the cause: since the index self-heal, opening the
248            // vault can fail on a cache probe error (e.g. the cache is locked
249            // by another kimun process). The app falls back to the no-vault
250            // start screen either way, but the reason must reach the log
251            // instead of looking like an unconfigured workspace.
252            Err(e) => {
253                tracing::error!("could not open vault at {}: {e}", workspace);
254                None
255            }
256        }
257    }
258}
259
260/// (Re)starts the background RAG sync for the current vault: aborts any prior
261/// task and spawns a fresh one bound to the current `app.vault`. A no-op sync
262/// (no server configured, or no vault) leaves the task `None`.
263fn respawn_rag(app: &mut App, tx: &crate::components::events::AppTx) {
264    if let Some(handle) = app.rag_sync_task.take() {
265        handle.abort();
266    }
267    if let Some(vault) = app.vault.clone() {
268        app.rag_sync_task = crate::rag::spawn_rag_sync(vault, &app.settings, tx.clone());
269    }
270}
271
272async fn switch_screen(app: &mut App, tx: &AppTx, new_screen: ScreenEvent) {
273    app.current_screen.on_exit(tx).await;
274
275    // Decided before `new_screen` is consumed below. Only the editor handles
276    // `FlashMessage`; every other screen drops it on the floor.
277    let shows_flashes = matches!(new_screen, ScreenEvent::OpenEditor(..));
278
279    let mut screen: Box<dyn AppScreen> = match new_screen {
280        ScreenEvent::Start => Box::new(StartScreen::new(app.settings.clone(), app.vault.clone())),
281        ScreenEvent::OpenPreferences => Box::new(PreferencesScreen::new(app.settings.clone())),
282        ScreenEvent::OpenPreferencesWithError(msg) => {
283            Box::new(PreferencesScreen::new_with_error(app.settings.clone(), msg))
284        }
285        ScreenEvent::OpenEditor(note_vault, vault_path) => Box::new(EditorScreen::new(
286            note_vault,
287            vault_path,
288            app.settings.clone(),
289        )),
290        ScreenEvent::OpenBrowse(note_vault, vault_path) => Box::new(BrowseScreen::new(
291            note_vault,
292            vault_path,
293            app.settings.clone(),
294        )),
295        ScreenEvent::OpenOnboarding => Box::new(OnboardingScreen::new(app.settings.clone())),
296    };
297
298    screen.on_enter(tx).await;
299    // Seed the freshly-created screen with any pending update notice, so the
300    // editor footer shows it even though the check finished before this screen
301    // existed. Non-editor screens ignore the event.
302    if let Some(status) = app.update.clone() {
303        screen
304            .handle_app_message(AppEvent::Update(UpdateFlow::Available(status)), tx)
305            .await;
306    }
307    // Drained, not cloned: a flash is a one-shot, and re-firing it on every
308    // screen swap would turn a warning into a nag. Drained only for a screen
309    // that shows flashes — Start can route through Onboarding or Browse
310    // first, and handing the notice to one of those loses it for good.
311    // Joined into one flash: the footer holds a single one, so delivering
312    // them separately would leave only the last on screen.
313    if shows_flashes && !app.parked_flashes.is_empty() {
314        let msg = std::mem::take(&mut app.parked_flashes).join(" · ");
315        screen
316            .handle_app_message(AppEvent::FlashMessage(msg), tx)
317            .await;
318    }
319    screen
320        .handle_app_message(AppEvent::RagStatus(app.rag_status), tx)
321        .await;
322    app.current_screen = screen;
323    // Bumped here (not at every swap site) because every swap goes through
324    // this function. The main loop watches this counter to break its inner
325    // event drain whenever the screen identity changes, so the new screen is
326    // drawn before any event still queued is delivered to it.
327    app.screen_generation = app.screen_generation.wrapping_add(1);
328}
329
330/// Tell the user about a binding their terminal cannot deliver.
331///
332/// Their own, with one exception. The default keymap always keeps a chord
333/// that survives the weakest terminal kimün supports — `settings`' invariant
334/// test holds it to that — so anything found here came out of a
335/// `[key_bindings]` section and is theirs to change. The exception is the
336/// Ctrl-H rewrite: under a Backspace policy `FocusSidebar` loses its only
337/// default chord, and the notice then names `ctrl_h` rather than the
338/// terminal (see `notice_text`). That is the whole reason this reports rather
339/// than silently repairing: rebinding under them is exactly the surprise
340/// moving formatting to the leader was meant to avoid.
341///
342/// Returns the notice rather than sending it: at this point **Start** is the
343/// live screen and only the editor handles `FlashMessage`, so it is parked on
344/// [`App::parked_flashes`] for `switch_screen` to deliver once an editor is
345/// opened.
346///
347/// Rare in practice, and deliberately so: `merge_missing_default_bindings`
348/// hands an action its default combo back unless the config gave that combo to
349/// something else. So `SearchNotes = ["ctrl&I"]` alone still answers to Ctrl-K
350/// and stays reachable; it takes a config that *also* claims Ctrl-K elsewhere
351/// to strand it. Quiet is the point — a warning that fired on a working setup
352/// would teach people to ignore it.
353///
354/// Logged as a warning (durable — the troubleshooting docs send people to the
355/// log) and flashed once in the footer (visible, and gone in two seconds
356/// rather than nagging). `kimun doctor` prints the full picture on demand.
357fn unreachable_binding_notice(
358    app: &App,
359    session: &terminal::TerminalSession,
360    setting: crate::settings::CtrlHSetting,
361    ctrl_h: ctrl_h::CtrlHPolicy,
362) -> Option<String> {
363    use crate::keys::reachability::{TerminalKeys, unreachable_actions};
364
365    let keys = TerminalKeys::detected(
366        session.keyboard_enhanced(),
367        // `FocusSidebar`'s only default chord is `Ctrl+H`, so under the
368        // rewrite it is stranded. The rule for when that is worth saying
369        // lives with the policy.
370        //
371        // Deliberately not `policy == CtrlHPolicy::Backspace` — what
372        // `doctor.rs` passes for this same argument. Under an explicit
373        // `ctrl_h = "backspace"` `loss_is_worth_reporting` is false, so this
374        // startup scan reports Ctrl+H as reachable while `kimun doctor`
375        // still reports it stranded: the flash is for surprises, quiet on a
376        // setting the user picked on purpose, while doctor always shows the
377        // full picture on request.
378        ctrl_h.loss_is_worth_reporting(setting),
379    );
380    let stranded = {
381        let settings = app.settings.read().unwrap();
382        unreachable_actions(&settings.key_bindings, keys)
383    };
384    if stranded.is_empty() {
385        return None;
386    }
387    for u in &stranded {
388        for (combo, fate) in &u.combos {
389            tracing::warn!("key binding {combo} for {}: {fate}", u.action);
390        }
391    }
392    Some(notice_text(&stranded, setting, ctrl_h))
393}
394
395/// The footer renders this flash as a single unwrapped, centre-aligned
396/// `Paragraph` (`components::footer_bar.rs`). Ratatui's centre offset is
397/// `(area/2).saturating_sub(line/2)`, which is `0` once the line is wider
398/// than the area — so a line past this many columns on an 80-column footer
399/// renders from the left and the TAIL is what gets clipped. Kept as a
400/// constant (rather than inlined into the test) so the budget has one place
401/// to change and a comment explaining why it exists.
402///
403/// Only asserted by `the_single_clause_notice_fits_the_flash_width_budget`
404/// below, not consulted by `notice_text` itself: the two-clause case reports
405/// two independent facts and cannot always fit it, so there is no single
406/// formatting rule this constant could gate at runtime.
407#[allow(
408    dead_code,
409    reason = "read by the width-budget test, not by notice_text"
410)]
411const FLASH_WIDTH_BUDGET: usize = 72;
412
413/// More than this many actions in one clause are named, then folded into an
414/// "and N more" tail — the flash has to stay on `FLASH_WIDTH_BUDGET`, not
415/// grow with however many bindings a user managed to strand.
416const FLASH_NAME_LIMIT: usize = 2;
417
418/// The footer line for a stranded-binding scan.
419///
420/// Partitioned by *cause*, not by policy. Under a Backspace policy the scan
421/// can turn up two different kinds of entry in the same call: the bare
422/// `Ctrl+H` chord that the rewrite itself shadowed with Backspace — kimün's
423/// own doing, `auto` read the tty's erase character or the user set
424/// `backspace` — and, independently, anything else the user's own
425/// `[key_bindings]` bound to a chord this terminal cannot deliver (a
426/// `SearchNotes = ["ctrl&I"]` colliding with Tab is always-on and has nothing
427/// to do with `ctrl_h`). Wrapping *that* action's name in "Ctrl+H is
428/// Backspace" would send the user off to change the wrong setting, so only
429/// the entries the rewrite actually caused get the ctrl_h wording; everything
430/// else keeps the plain "no usable key" line. Both clauses appear,
431/// semicolon-joined, when one scan turns up both causes.
432///
433/// Deliberately terse: this is a flash, not the report. It names the action,
434/// the `ctrl_h` setting responsible (when that is the cause), and points at
435/// `kimun doctor` — `stranded_advice` there is what actually says "rebind or
436/// set ctrl_h", in more words than a footer line can afford. Kept at or under
437/// [`FLASH_WIDTH_BUDGET`] for the common single-clause case so the pointer to
438/// `kimun doctor` is never the part that gets clipped.
439fn notice_text(
440    stranded: &[crate::keys::reachability::Unreachable],
441    setting: crate::settings::CtrlHSetting,
442    policy: ctrl_h::CtrlHPolicy,
443) -> String {
444    use crate::keys::key_combo::{KeyCombo, KeyModifiers};
445    use crate::keys::key_strike::KeyStrike;
446    use crate::keys::reachability::Reach;
447
448    // The exact pair the rewrite itself produces: `reach()` shadows the bare
449    // chord with the plain key, nothing else. Checking the pair (not just the
450    // combo) is what keeps a hand-built entry that merely mentions Ctrl+H
451    // under some other reach — or a real collision that happens to share the
452    // combo — out of this group.
453    let ctrl_h_combo = KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::KeyH);
454    let backspace_key = KeyCombo::new(KeyModifiers::new(), KeyStrike::Backspace);
455    let is_ctrl_h_loss = |u: &&crate::keys::reachability::Unreachable| {
456        policy == ctrl_h::CtrlHPolicy::Backspace
457            && u.combos.iter().any(|(combo, reach)| {
458                *combo == ctrl_h_combo && *reach == Reach::Shadowed(backspace_key)
459            })
460    };
461    let (ctrl_h_group, plain_group): (Vec<_>, Vec<_>) = stranded.iter().partition(is_ctrl_h_loss);
462
463    // Names two actions at most; anything past that is folded into a count so
464    // a heavily-remapped keymap cannot blow the width budget.
465    let join_names = |group: &[&crate::keys::reachability::Unreachable]| {
466        let names: Vec<String> = group.iter().map(|u| u.action.to_string()).collect();
467        if names.len() <= FLASH_NAME_LIMIT {
468            names.join(", ")
469        } else {
470            format!(
471                "{} and {} more",
472                names[..FLASH_NAME_LIMIT].join(", "),
473                names.len() - FLASH_NAME_LIMIT
474            )
475        }
476    };
477
478    let ctrl_h_clause = (!ctrl_h_group.is_empty()).then(|| {
479        let names = join_names(&ctrl_h_group);
480        let setting = format!("{setting:?}").to_lowercase();
481        format!("{names}: Ctrl+H is Backspace (ctrl_h = \"{setting}\")")
482    });
483    let plain_clause = (!plain_group.is_empty())
484        .then(|| format!("{}: no usable key here", join_names(&plain_group)));
485
486    match (ctrl_h_clause, plain_clause) {
487        (Some(a), None) => format!("{a} — run `kimun doctor`"),
488        (None, Some(b)) => format!("{b} — run `kimun doctor`"),
489        (Some(a), Some(b)) => format!("{a}; {b} — run `kimun doctor`"),
490        (None, None) => {
491            unreachable!("unreachable_binding_notice never calls this with an empty scan")
492        }
493    }
494}
495
496/// Kick off the background update check (gated on the user's `update_check`
497/// preference). All network/filesystem work runs on `spawn_blocking` inside
498/// `update::check_now`; a found update is surfaced via `AppEvent::Update(UpdateFlow::Available)`. Failures are logged and
499/// swallowed — the check never blocks startup or interaction.
500fn spawn_update_check(app: &App, tx: AppTx) {
501    if !app.settings.read().unwrap().update_check() {
502        return;
503    }
504    let Ok(config_dir) = crate::settings::config_dir() else {
505        return;
506    };
507    tokio::spawn(async move {
508        match crate::update::check_now(config_dir, false).await {
509            Ok(Some(status)) if status.should_notify() => {
510                let _ = tx.send(AppEvent::Update(UpdateFlow::Available(status)));
511            }
512            Ok(_) => {}
513            Err(e) => tracing::debug!("update check failed: {e}"),
514        }
515    });
516}
517
518/// The **App loop** (CONTEXT.md § App shell).
519///
520/// Draw the screen, wait for the next event, drain what is already queued,
521/// draw again. The rules that live here and nowhere else:
522///
523/// - Global shortcuts (`Quit`, `OpenPreferences`) fire before the screen
524///   sees the key.
525/// - Queued app messages are coalesced: one frame per batch. A real input
526///   event always comes through `next()` and so always gets its own draw.
527/// - A screen swap mid-drain ends the drain (`App::screen_generation`), so the
528///   new screen is drawn before any event still queued is delivered to it.
529///   The queued events are *not* dropped — they are read again on the next
530///   iteration and reach the new screen. Filtering stale results is the job
531///   of the addressed event families (`OverlayData`, `Ask`) and per-event
532///   guards, not of this loop.
533/// - `Quit` runs the current screen's `on_exit` before returning.
534///
535/// Generic over the terminal backend and fed by an **Input source**, so it
536/// runs headless in tests (`tests/app_loop_test.rs`).
537pub async fn run_app<B: Backend>(
538    terminal: &mut Terminal<B>,
539    app: &mut App,
540    events: &mut EventHandler,
541) -> io::Result<()>
542where
543    B::Error: std::error::Error + Send + Sync + 'static,
544{
545    let tx = events.app_sender();
546
547    app.current_screen.on_enter(&tx).await;
548
549    loop {
550        terminal
551            .draw(|f| app.current_screen.render(f))
552            // A `From` bound into `io::Error` would exclude `TestBackend`
553            // (`Error = Infallible`), which the headless loop tests use.
554            // Wrapping through `io::Error::other` keeps the source error and
555            // needs only the standard error bounds.
556            .map_err(io::Error::other)?;
557
558        // Block until at least one event arrives, then drain everything else
559        // that is already queued before drawing again. `Redraw` events are
560        // coalesced — the top-of-loop draw paints one frame for the whole
561        // batch instead of one frame per pending message. Crossterm input
562        // events never come through the mpsc channel, so a real key event
563        // always forces a fresh `events.next().await` (and therefore a
564        // dedicated draw) on the next iteration.
565        let mut event = events.next().await;
566        loop {
567            match event {
568                AppEvent::Quit => {
569                    app.current_screen.on_exit(&tx).await;
570                    return Ok(());
571                }
572                AppEvent::Redraw => {
573                    // No-op: top-of-loop draw already happened (or is about to).
574                }
575                AppEvent::Input(input) => {
576                    match input {
577                        InputEvent::Key(key) => {
578                            tracing::debug!(
579                                "KEY: code={:?} mods={:?} kind={:?}",
580                                key.code,
581                                key.modifiers,
582                                key.kind
583                            );
584                            // Global shortcuts — fire before any screen gets the event.
585                            if let Some(combo) = key_event_to_combo(&key) {
586                                let action = {
587                                    let s = app.settings.read().unwrap();
588                                    tracing::debug!(
589                                        "COMBO: {} → {:?}",
590                                        combo,
591                                        s.key_bindings.get_action(&combo)
592                                    );
593                                    s.key_bindings.get_action(&combo)
594                                };
595                                let handled_global = match action {
596                                    Some(ActionShortcuts::Quit) => {
597                                        tx.send(AppEvent::Quit).ok();
598                                        true
599                                    }
600                                    Some(ActionShortcuts::OpenPreferences) => {
601                                        let already_on_settings = app.current_screen.get_kind()
602                                            == ScreenKind::Preferences;
603                                        if !already_on_settings {
604                                            tx.send(AppEvent::OpenScreen(
605                                                ScreenEvent::OpenPreferences,
606                                            ))
607                                            .ok();
608                                        }
609                                        true
610                                    }
611                                    _ => false,
612                                };
613                                if handled_global {
614                                    // Skip screen-level handling for this key.
615                                    match events.try_next() {
616                                        Some(next) => {
617                                            event = next;
618                                            continue;
619                                        }
620                                        None => break,
621                                    }
622                                }
623                            }
624                            app.current_screen.handle_input(&InputEvent::Key(key), &tx);
625                        }
626                        InputEvent::Mouse(mouse_event) => {
627                            app.current_screen
628                                .handle_input(&InputEvent::Mouse(mouse_event), &tx);
629                        }
630                        InputEvent::Paste(text) => {
631                            app.current_screen
632                                .handle_input(&InputEvent::Paste(text), &tx);
633                        }
634                    }
635                }
636                msg => {
637                    // Capture screen identity around handle_app_message so we
638                    // can detect a synchronous screen swap (OpenScreen,
639                    // VaultConflict). Use `screen_generation` rather than
640                    // `ScreenKind`, because a swap between two screens of the
641                    // same kind (e.g. EditorScreen(A) → follow-link →
642                    // EditorScreen(B)) still leaks A's queued events into B
643                    // if we only compare kinds. Remaining queued events
644                    // belong to the OLD screen instance — break the drain so
645                    // they get a fresh outer iteration where they are routed
646                    // correctly (and the new screen gets its first draw
647                    // before further input).
648                    let before_gen = app.screen_generation;
649                    handle_app_message(msg, app, &tx).await?;
650                    if app.screen_generation != before_gen {
651                        break;
652                    }
653                }
654            }
655            match events.try_next() {
656                Some(next) => event = next,
657                None => break,
658            }
659        }
660    }
661}
662
663async fn handle_app_message(msg: AppEvent, app: &mut App, tx: &AppTx) -> io::Result<()> {
664    match msg {
665        AppEvent::Redraw => {}
666        AppEvent::OpenScreen(screen) => {
667            switch_screen(app, tx, screen).await;
668        }
669        AppEvent::ParkFlash(msg) => app.parked_flashes.push(msg),
670        AppEvent::OpenPath { path, emphasis } => {
671            // We either handle the new path within the current screen, or we switch to a new screen for this path
672            let unhandled = app.current_screen.try_open_path(path, emphasis, tx).await;
673            if let Some(path) = unhandled {
674                if let Some(vault) = app.vault.clone() {
675                    if path.is_note() {
676                        tx.send(AppEvent::OpenScreen(ScreenEvent::OpenEditor(vault, path)))
677                            .ok();
678                    } else {
679                        tx.send(AppEvent::OpenScreen(ScreenEvent::OpenBrowse(vault, path)))
680                            .ok();
681                    }
682                } else {
683                    // No vault → the app is unconfigured. Route to the guided
684                    // setup, not Preferences (onboarding replaces the
685                    // preferences fallthrough as the no-workspace path).
686                    tx.send(AppEvent::OpenScreen(ScreenEvent::OpenOnboarding))
687                        .ok();
688                }
689            }
690        }
691        AppEvent::OpenAttachment(path) => {
692            // The editor screen shows it in its attachment view; any other
693            // screen routes through OpenEditor first, then the attachment opens
694            // there. (In practice this is sent from the editor's FILES drawer.)
695            let unhandled = app.current_screen.try_open_attachment(path, tx).await;
696            if let Some(path) = unhandled
697                && let Some(vault) = app.vault.clone()
698            {
699                tx.send(AppEvent::OpenScreen(ScreenEvent::OpenEditor(vault, path)))
700                    .ok();
701            }
702        }
703        AppEvent::OpenJournal => {
704            // Resolve today's journal entry (creating it if needed) once, then
705            // route it like any other note via OpenPath so it works from every
706            // screen — the current screen opens it inline or the loop switches
707            // to the editor.
708            if let Some(vault) = app.vault.clone()
709                && let Ok((details, _, created)) = vault.journal_entry().await
710            {
711                // Notify the current screen's sidebar when freshly created, then
712                // open it — works from every screen via OpenPath.
713                tx.announce_and_open(details.path, created);
714            }
715        }
716        AppEvent::PreferencesSaved | AppEvent::OnboardingFinished => {
717            // Rebuild the vault so workspace path and inbox_path changes take effect.
718            app.vault = App::open_vault(&app.settings).await;
719            respawn_rag(app, tx);
720            tx.send(AppEvent::OpenScreen(ScreenEvent::Start)).ok();
721        }
722        AppEvent::ClosePreferences => {
723            tx.send(AppEvent::OpenScreen(ScreenEvent::Start)).ok();
724        }
725        AppEvent::VaultConflict(msg) => {
726            // The vault has structural conflicts (e.g. case-insensitive path clashes).
727            // Clear the workspace so the user is not stuck in a loop, then show
728            // the settings screen with the error overlay pre-populated.
729            {
730                let mut s = app.settings.write().unwrap();
731                s.clear_workspace();
732                s.save_to_disk().ok();
733            }
734            app.vault = None;
735            respawn_rag(app, tx);
736            switch_screen(app, tx, ScreenEvent::OpenPreferencesWithError(msg)).await;
737        }
738        AppEvent::WorkspaceSwitched(name) => {
739            {
740                let mut s = app.settings.write().unwrap();
741                if let Some(ref mut wc) = s.workspace_config {
742                    wc.global.current_workspace = name;
743                }
744                s.save_to_disk().ok();
745            }
746            app.vault = App::open_vault(&app.settings).await;
747            respawn_rag(app, tx);
748            tx.send(AppEvent::OpenScreen(ScreenEvent::Start)).ok();
749        }
750        AppEvent::Update(flow) => {
751            // The app-global half of the update lifecycle: remember the
752            // notice (so a later-opened editor is seeded in switch_screen),
753            // persist a dismissal, clear on dismiss/install. Every flow
754            // event is then forwarded — the editor screen owns the display
755            // half (indicator, dialog, running the install).
756            match &flow {
757                UpdateFlow::Available(status) => app.update = Some(status.clone()),
758                UpdateFlow::Dismiss(version) => {
759                    if let Ok(config_dir) = crate::settings::config_dir()
760                        && let Err(e) = crate::update::dismiss(&config_dir, version)
761                    {
762                        tracing::debug!("could not persist update dismissal: {e}");
763                    }
764                    app.update = None;
765                }
766                UpdateFlow::Applied => app.update = None,
767                UpdateFlow::Apply | UpdateFlow::ShowDialog => {}
768            }
769            app.current_screen
770                .handle_app_message(AppEvent::Update(flow), tx)
771                .await;
772        }
773        AppEvent::RagStatus(status) => {
774            // Same pattern as update: keep app-globally for screen seeding, and
775            // forward for immediate display.
776            app.rag_status = status;
777            app.current_screen
778                .handle_app_message(AppEvent::RagStatus(status), tx)
779                .await;
780        }
781        AppEvent::FileOp(op) => {
782            // History is app-wide bookkeeping; keep it in step here, whichever
783            // screen hosted the operation, then let the screen react.
784            match &op {
785                FileOp::Renamed { from, to } | FileOp::Moved { from, to } => {
786                    app.settings
787                        .read()
788                        .unwrap()
789                        .follow_rename_in_history(from, to);
790                }
791                FileOp::Deleted(path) => {
792                    app.settings.read().unwrap().follow_delete_in_history(path);
793                }
794                _ => {}
795            }
796            app.current_screen
797                .handle_app_message(AppEvent::FileOp(op), tx)
798                .await;
799        }
800        other => {
801            app.current_screen.handle_app_message(other, tx).await;
802        }
803    }
804    Ok(())
805}
806
807#[cfg(test)]
808mod tests {
809    use std::sync::{Arc, RwLock};
810
811    use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
812
813    use super::App;
814    use crate::app_screen::ScreenKind;
815    use crate::keys::action_shortcuts::ActionShortcuts;
816    use crate::keys::key_event_to_combo;
817    use crate::settings::AppSettings;
818
819    #[tokio::test]
820    async fn from_settings_without_a_workspace_starts_on_the_start_screen_with_no_vault() {
821        let settings = Arc::new(RwLock::new(AppSettings::default()));
822        let app = App::from_settings(settings).await;
823        assert!(app.vault.is_none());
824        assert_eq!(app.current_screen.get_kind(), ScreenKind::Start);
825        assert_eq!(app.screen_generation, 0);
826    }
827
828    /// Ctrl+, is the global shortcut for OpenPreferences, handled in `run_app`
829    /// before any screen.
830    #[test]
831    fn settings_keybinding_sends_open_settings() {
832        let settings = AppSettings::default();
833        let key = KeyEvent {
834            code: KeyCode::Char(','),
835            modifiers: KeyModifiers::CONTROL,
836            kind: KeyEventKind::Press,
837            state: KeyEventState::NONE,
838        };
839
840        let combo = key_event_to_combo(&key).expect("Ctrl+, should produce a combo");
841        let action = settings.key_bindings.get_action(&combo);
842        assert_eq!(action, Some(ActionShortcuts::OpenPreferences));
843    }
844
845    /// The startup key warning is parked until a screen that can show it.
846    /// Start can route to Onboarding or Browse before any editor exists;
847    /// consuming the flash there loses the only notice the user gets.
848    #[tokio::test]
849    async fn the_key_warning_waits_for_a_screen_that_shows_flashes() {
850        use crate::components::events::ScreenEvent;
851        use kimun_core::nfs::VaultPath;
852        use kimun_core::{NoteVault, VaultConfig};
853
854        let settings = Arc::new(RwLock::new(AppSettings::default()));
855        let mut app = App::from_settings(settings).await;
856        app.parked_flashes.push("stranded".to_string());
857        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
858
859        super::switch_screen(&mut app, &tx, ScreenEvent::OpenOnboarding).await;
860        assert_eq!(
861            app.parked_flashes,
862            vec!["stranded".to_string()],
863            "onboarding cannot show a flash, so the warning must still be parked"
864        );
865
866        let dir = tempfile::TempDir::new().unwrap();
867        let vault = Arc::new(
868            NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
869                .await
870                .unwrap(),
871        );
872        super::switch_screen(
873            &mut app,
874            &tx,
875            ScreenEvent::OpenEditor(vault, VaultPath::root()),
876        )
877        .await;
878        assert!(
879            app.parked_flashes.is_empty(),
880            "the editor shows flashes, so the one-shot is delivered and gone"
881        );
882    }
883
884    /// An app whose current workspace's history is `history`, newest first,
885    /// kept in `scratch` (the config file too).
886    async fn app_with_history(history: &[&str], scratch: &tempfile::TempDir) -> App {
887        let workspace = crate::test_support::sys(scratch.path());
888        let settings = AppSettings::for_test_workspace("ws", &workspace, workspace.clone());
889        settings.seed_test_history(history);
890        App::from_settings(Arc::new(RwLock::new(settings))).await
891    }
892
893    async fn file_op(app: &mut App, op: crate::components::events::FileOp) {
894        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
895        super::handle_app_message(crate::components::events::AppEvent::FileOp(op), app, &tx)
896            .await
897            .unwrap();
898    }
899
900    /// The history as plain strings, form-independent (entries may be stored
901    /// relative or absolute).
902    fn history_of(app: &App) -> Vec<String> {
903        app.settings
904            .read()
905            .unwrap()
906            .current_last_paths()
907            .iter()
908            .map(|p| {
909                let mut p = p.clone();
910                p.to_relative();
911                p.to_string()
912            })
913            .collect()
914    }
915
916    /// A rename done inside kimün must follow through to the history, or the
917    /// next launch finds the old path gone and blames an outside change.
918    #[tokio::test]
919    async fn renaming_a_note_rewrites_it_in_the_history() {
920        use crate::components::events::FileOp;
921        use kimun_core::nfs::VaultPath;
922
923        let scratch = tempfile::TempDir::new().unwrap();
924        let mut app = app_with_history(&["a.md", "other.md"], &scratch).await;
925        file_op(
926            &mut app,
927            FileOp::Renamed {
928                from: VaultPath::new("a.md"),
929                to: VaultPath::new("b.md"),
930            },
931        )
932        .await;
933        assert_eq!(history_of(&app), vec!["b.md", "other.md"]);
934    }
935
936    #[tokio::test]
937    async fn moving_a_directory_rewrites_the_notes_beneath_it() {
938        use crate::components::events::FileOp;
939        use kimun_core::nfs::VaultPath;
940
941        let scratch = tempfile::TempDir::new().unwrap();
942        let mut app = app_with_history(&["dir/a.md", "dirx/b.md"], &scratch).await;
943        file_op(
944            &mut app,
945            FileOp::Moved {
946                from: VaultPath::new("dir"),
947                to: VaultPath::new("archive/dir"),
948            },
949        )
950        .await;
951        assert_eq!(history_of(&app), vec!["archive/dir/a.md", "dirx/b.md"]);
952    }
953
954    #[tokio::test]
955    async fn deleting_drops_the_note_or_everything_beneath_the_directory() {
956        use crate::components::events::FileOp;
957        use kimun_core::nfs::VaultPath;
958
959        let scratch = tempfile::TempDir::new().unwrap();
960        let mut app =
961            app_with_history(&["a.md", "dir/b.md", "dirx/c.md", "keep.md"], &scratch).await;
962        file_op(&mut app, FileOp::Deleted(VaultPath::new("a.md"))).await;
963        file_op(&mut app, FileOp::Deleted(VaultPath::new("dir"))).await;
964        assert_eq!(history_of(&app), vec!["dirx/c.md", "keep.md"]);
965    }
966
967    /// The footer holds one flash at a time, so notices parked together must
968    /// reach it together — delivered one by one, each overwrites the last
969    /// before a frame is ever drawn.
970    #[tokio::test]
971    async fn every_parked_flash_reaches_the_footer() {
972        use crate::components::events::{AppEvent, ScreenEvent};
973        use kimun_core::nfs::VaultPath;
974
975        let settings = Arc::new(RwLock::new(AppSettings::default()));
976        let mut app = App::from_settings(settings).await;
977        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
978        for notice in ["first notice", "second notice"] {
979            super::handle_app_message(AppEvent::ParkFlash(notice.to_string()), &mut app, &tx)
980                .await
981                .unwrap();
982        }
983
984        let vault = crate::test_support::temp_vault("app").await;
985        super::switch_screen(
986            &mut app,
987            &tx,
988            ScreenEvent::OpenEditor(vault, VaultPath::root()),
989        )
990        .await;
991
992        let mut terminal =
993            ratatui::Terminal::new(ratatui::backend::TestBackend::new(160, 30)).unwrap();
994        terminal.draw(|f| app.current_screen.render(f)).unwrap();
995        let flat: String = terminal
996            .backend()
997            .buffer()
998            .content
999            .iter()
1000            .map(|c| c.symbol())
1001            .collect();
1002        assert!(flat.contains("first notice"), "first notice lost");
1003        assert!(flat.contains("second notice"), "second notice lost");
1004    }
1005
1006    /// When `auto` chose Backspace the stranded action is kimün's own
1007    /// default, and the flash has to say which setting did it — "run doctor"
1008    /// alone reads as a broken install.
1009    #[test]
1010    fn the_notice_names_ctrl_h_when_auto_chose_backspace() {
1011        use crate::app::ctrl_h::CtrlHPolicy;
1012        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1013        use crate::keys::key_strike::KeyStrike;
1014        use crate::keys::reachability::{Reach, Unreachable};
1015        use crate::settings::CtrlHSetting;
1016
1017        let ctrl_h = KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::KeyH);
1018        let stranded = vec![Unreachable {
1019            action: ActionShortcuts::FocusSidebar,
1020            combos: vec![(
1021                ctrl_h,
1022                Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Backspace)),
1023            )],
1024        }];
1025
1026        let auto = super::notice_text(&stranded, CtrlHSetting::Auto, CtrlHPolicy::Backspace);
1027        assert!(auto.contains("FocusSidebar"), "{auto}");
1028        assert!(auto.contains("ctrl_h"), "{auto}");
1029        assert!(auto.contains("kimun doctor"), "{auto}");
1030
1031        // A stranding the rewrite had no part in keeps the plain wording.
1032        let theirs = super::notice_text(&stranded, CtrlHSetting::Auto, CtrlHPolicy::Chord);
1033        assert!(!theirs.contains("ctrl_h"), "{theirs}");
1034        assert!(theirs.contains("no usable key"), "{theirs}");
1035    }
1036
1037    /// A single scan can turn up both causes at once: the rewrite's own loss
1038    /// and an unrelated collision out of the user's own `[key_bindings]`.
1039    /// Wrapping that second action's name in the ctrl_h wording would send
1040    /// the user off to change the wrong setting, so the two clauses must
1041    /// stay apart in the string, not just both appear somewhere in it.
1042    #[test]
1043    fn the_notice_keeps_an_unrelated_stranding_out_of_the_ctrl_h_clause() {
1044        use crate::app::ctrl_h::CtrlHPolicy;
1045        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1046        use crate::keys::key_strike::KeyStrike;
1047        use crate::keys::reachability::{Reach, Unreachable};
1048        use crate::settings::CtrlHSetting;
1049
1050        let ctrl_h = KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::KeyH);
1051        let ctrl_i = KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::KeyI);
1052        let stranded = vec![
1053            Unreachable {
1054                action: ActionShortcuts::FocusSidebar,
1055                combos: vec![(
1056                    ctrl_h,
1057                    Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Backspace)),
1058                )],
1059            },
1060            Unreachable {
1061                action: ActionShortcuts::QuickNote,
1062                combos: vec![(
1063                    ctrl_i,
1064                    Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Tab)),
1065                )],
1066            },
1067        ];
1068
1069        let text = super::notice_text(&stranded, CtrlHSetting::Auto, CtrlHPolicy::Backspace);
1070        let ctrl_h_clause = text
1071            .split_once("; ")
1072            .map(|(first, _)| first)
1073            .unwrap_or_else(|| panic!("expected the ctrl_h clause joined with '; ': {text}"));
1074        assert!(
1075            ctrl_h_clause.contains("FocusSidebar"),
1076            "ctrl_h clause: {ctrl_h_clause}"
1077        );
1078        assert!(
1079            !ctrl_h_clause.contains("QuickNote"),
1080            "ctrl_h clause: {ctrl_h_clause}"
1081        );
1082        assert!(text.contains("QuickNote"), "{text}");
1083    }
1084
1085    /// Pins the width budget: the footer renders this flash as an unwrapped,
1086    /// centre-aligned `Paragraph` (`components::footer_bar.rs`), so a line
1087    /// wider than the terminal is clipped from the *right* — losing the
1088    /// `kimun doctor` pointer this flash exists to deliver. The common
1089    /// scenario (the shipped default, `auto` chose Backspace) must fit an
1090    /// 80-column footer with room to spare. A regression here means someone
1091    /// widened the wording without checking it still fits.
1092    #[test]
1093    fn the_single_clause_notice_fits_the_flash_width_budget() {
1094        use crate::app::ctrl_h::CtrlHPolicy;
1095        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1096        use crate::keys::key_strike::KeyStrike;
1097        use crate::keys::reachability::{Reach, Unreachable};
1098        use crate::settings::CtrlHSetting;
1099
1100        let ctrl_h = KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::KeyH);
1101        let stranded = vec![Unreachable {
1102            action: ActionShortcuts::FocusSidebar,
1103            combos: vec![(
1104                ctrl_h,
1105                Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Backspace)),
1106            )],
1107        }];
1108
1109        let text = super::notice_text(&stranded, CtrlHSetting::Auto, CtrlHPolicy::Backspace);
1110        assert!(
1111            text.chars().count() <= super::FLASH_WIDTH_BUDGET,
1112            "{} chars, over the {}-column budget: {text}",
1113            text.chars().count(),
1114            super::FLASH_WIDTH_BUDGET
1115        );
1116    }
1117}