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