//! Main event loop. //! //! A thin `tokio::select!` loop. All input routing, rendering, and state //! management is delegated to [`AppView`]. The event loop only handles //! IO plumbing: terminal events, ACP channel, spawned task results, //! animation ticks, and hot-reloadable config changes. use std::time::Duration; use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; use tokio::task::JoinSet; use tokio::time::{Instant, sleep_until}; use crate::appearance::ConfigWatcher; use crate::client_identity::{PAGER_CLIENT_TYPE, PAGER_CLIENT_VERSION}; use crate::theme::system_appearance::{self, SystemAppearanceWatcher}; use crate::theme::{Theme, ThemeKind, cache as theme_cache}; use agent_client_protocol as acp; use kigi_acp_lib::acp_send; use super::actions::{Action, Effect, TaskResult}; use super::app_view::{ActiveView, AppView, AuthState, InputOutcome, PasteProvenance, TrustState}; use super::{PagerArgs, PagerTerminal, acp_handler, dispatch, effects}; /// Values resolved before `init_terminal` and consumed by the event loop. /// /// All fields must be computed while stdin is still in cooked mode and /// crossterm has not yet taken it over. pub(crate) struct TerminalState { pub is_control_mode: bool, pub screen_mode: super::ScreenMode, /// One-shot `/minimal` re-exec (env override already consumed). pub relaunched_into_minimal: bool, /// One-shot `/fullscreen` re-exec (env override already consumed). pub relaunched_into_fullscreen: bool, /// Do NOT re-resolve via `theme::cache::resolve_initial_theme()` here: /// its OSC 11 fallback reads stdin and competes with the input reader. pub initial_theme: ThemeKind, } /// Result of the event loop run. pub(crate) struct RunResult { pub exit_info: Option, pub quit_for_update: bool, /// When set, the process should re-exec into the other screen mode after /// terminal restore. See `/minimal` and `/fullscreen`. pub relaunch: Option, } /// In-flight reconnect re-initialization, tied to the agents whose reload /// windows it opened so completion lands on them even if the user switches /// views (or closes one) while the re-init runs. struct ReconnectReinit { rx: tokio::sync::oneshot::Receiver, /// Agents being reloaded, active tab first; empty when the reconnect /// happened with no open sessions (init/auth are still re-run). agent_ids: Vec, /// Reconnect generation that opened the reload windows. generation: u64, } /// Result of a reconnect re-initialization task. struct ReinitOutcome { /// Whether initialize/authenticate succeeded; when false no load was /// attempted and `loads` is empty (every window finalizes as failed). init_ok: bool, loads: Vec, } /// Per-agent `session/load` outcome from the re-init task. struct AgentLoadOutcome { agent_id: super::agent::AgentId, success: bool, /// `kigi/runningPromptId` from the reload response: the turn another /// client is driving mid-reconnect, adopted at finalize (mirrors the /// `SessionLoaded` adoption in `dispatch.rs`). running_prompt_id: Option, } /// Fields of the reconnect `session/load`, derived from the agent being /// reloaded. `None` when the agent has no session yet. struct ReconnectLoadPlan { session_id: acp::SessionId, /// The session's own cwd — its on-disk storage key — falling back to the /// pager cwd only when unset. The pager cwd only matches sessions started /// in it; worktree/cross-cwd sessions would fail to reload. cwd: std::path::PathBuf, /// `yoloMode` plus the optional reconnect `cursor`: the agent replays /// only the post-cursor tail (as live updates) when it finds the eventId, /// and full-replays when it doesn't. meta: serde_json::Value, } fn plan_reconnect_load( agent: &super::agent_view::AgentView, fallback_cwd: &std::path::Path, ) -> Option { let session_id = agent.session.session_id.clone()?; let cwd = if agent.session.cwd.as_os_str().is_empty() { fallback_cwd.to_path_buf() } else { agent.session.cwd.clone() }; let yolo = agent.session.is_yolo(); // Set BOTH yoloMode and autoMode explicitly. The leader's capability injection // only fills ABSENT keys, so omitting autoMode here lets a stale launch-time // `ClientCapabilities.auto_mode` re-enable Auto after the user left it (e.g. // Shift+Tab to Ask). Auto is per-agent (symmetric with yolo) — derive it from // this agent's own `auto_mode` so a background tab reconnects with ITS mode, // not the active tab's global `current_ui` mirror. let auto = super::dispatch::effective_auto(yolo, agent.session.is_auto()); let mut meta = serde_json::json!({ "yoloMode": yolo, "autoMode": auto }); if let Some(ref cursor) = agent.last_seen_event_id { meta["cursor"] = serde_json::Value::String(cursor.clone()); } Some(ReconnectLoadPlan { session_id, cwd, meta, }) } /// Resolve the two post-reconnect restore outcomes from the per-agent /// `session/load` results. /// /// - `all_restored` (AND across every reloaded tab, plus `init_ok`) drives the /// user-facing toast: it reports whether the WHOLE reconnect came back. /// - `active_restored` is per-agent: the ACTIVE tab's OWN reload succeeded. It /// gates that tab's post-reconnect queue drain. Gating the drain on /// `all_restored` would let one failed background tab strand prompts queued /// on a healthy active tab — the drain (`dispatch_drain_queue`) only ever /// touches the active agent, so a background failure has no bearing on it. /// /// `loads` maps each reloaded agent to `(success, running_prompt_id)`; an agent /// in `pending_agent_ids` but absent from `loads` is treated as failed /// (mirrors the `unwrap_or((false, _))` at the finalize site). fn reconnect_restore_outcome( init_ok: bool, pending_agent_ids: &[super::agent::AgentId], loads: &std::collections::HashMap)>, active_agent_id: Option, ) -> (bool, bool) { let load_ok = |id: &super::agent::AgentId| -> bool { loads.get(id).is_some_and(|(ok, _)| *ok) }; let all_restored = init_ok && pending_agent_ids.iter().all(load_ok); let active_restored = init_ok && active_agent_id.is_some_and(|aid| pending_agent_ids.contains(&aid) && load_ok(&aid)); (all_restored, active_restored) } /// Compute the folder-trust verdict for the session cwd and seed /// [`AppView::trust_state`]. Pager-side mirror of the agent's resolve: read the /// local store, scan for repo-local code-exec config, and run the pure /// [`decide`](kigi_workspace::folder_trust::decide) precedence. /// /// `TrustOutcome::Prompt` (interactive + untrusted + repo configs present) /// becomes `TrustState::Pending` (show the question); everything else becomes /// `TrustState::Done`. The feature-off fast path (kill-switch / opt-out / /// local build) short-circuits before any I/O. fn seed_trust_state(app: &mut AppView, remote: Option<&kigi_shell::util::config::RemoteSettings>) { use kigi_workspace::folder_trust::{ TrustOutcome, decide, decide_inputs_with_interactive, feature_enabled, }; use kigi_workspace::trust::workspace_key; use std::io::IsTerminal; let feature = feature_enabled(remote); if !feature { app.trust_state = TrustState::Done; return; } // The cwd the user launched in == the process cwd == `app.cwd` (set at // construction), matching the `--trust` grant's `std::env::current_dir()`. let cwd = app.cwd.clone(); let key = workspace_key(&cwd); // Reuse the canonical gather (store trust + repo-config scan) but pass the // pager's stdin-only interactivity: the TUI prompts via the rendered // question + crossterm keyboard, NOT stderr (the pager redirects native // stderr at startup, so the engine's `stdin && stderr` would be false here // and the question would never show). TTY stdin => user can answer; // otherwise fail closed (no prompt). let inputs = decide_inputs_with_interactive(&cwd, &key, std::io::stdin().is_terminal()); app.trust_state = match decide(feature, &inputs) { TrustOutcome::Prompt => TrustState::Pending { workspace: key }, TrustOutcome::Trusted | TrustOutcome::Untrusted => TrustState::Done, }; } /// Suspend the inline TUI, run a blocking child that takes over the tty /// (`$EDITOR`, `$PAGER`, …), then restore. Shared by the editor and transcript /// suspend paths so the subtle reader-park + raw-mode + alt-screen handoff lives /// in one place. /// /// The reader thread is parked first so the child (which inherits this tty) /// keeps every keystroke instead of racing the reader; on return, buffered /// terminal query replies and any pre-park keystroke are drained before the /// reader resumes. /// /// The frame **writer** thread is then drained (bounded) before the child /// starts: frames are written to the tty asynchronously, and the frame that /// armed this suspend (e.g. minimal's final `/transcript` pump slice) is /// typically queued microseconds before we get here. Un-drained, those bytes /// race the child's own output — they can land on the child's alternate /// screen (so the main screen never receives them; on an inline viewport a /// commit's scroll then leaves every following row off by its height) or tear /// around the alt-screen switch, printing escape fragments (`[`…) that the /// renderer's diff can't see and thus never repairs. /// /// In minimal mode the physical cursor is probed (`ESC[6n`) right before the /// child runs and right after it exits — while the reader thread is still /// parked, so the replies can't be stolen. The returned "cursor after the /// child, iff it moved" tells the caller whether the child *printed to the /// main screen* (cat-style pager: cursor left below its output) or *restored /// it* (less-style alt-screen pager: `rmcup` puts the cursor back exactly /// where it was): the caller re-anchors the live region below the new output /// in the first case and repaints in place in the second. fn suspend_for_child( screen_mode: crate::app::ScreenMode, writer_sync: &crate::render::draw::WriterSync, input_paused: &std::sync::atomic::AtomicBool, reader_parked: &std::sync::atomic::AtomicBool, input_rx: &mut tokio::sync::mpsc::UnboundedReceiver, run_child: impl FnOnce(), ) -> Option<(u16, u16)> { use std::sync::atomic::Ordering; // Pause the reader thread, then wait for a FRESH park (reader provably out of // crossterm) so the main thread is the sole poll/read caller; bounded so a // dead reader can't hang us. input_paused.store(true, Ordering::Release); reader_parked.store(false, Ordering::Release); let park_deadline = std::time::Instant::now() + Duration::from_millis(500); while !reader_parked.load(Ordering::Acquire) && std::time::Instant::now() < park_deadline { std::thread::sleep(Duration::from_millis(5)); } // Every queued frame must be ON the tty before the child takes it (and, in // fullscreen, before LeaveAlternateScreen below — mirroring teardown's // "no late frame after LeaveAlternateScreen" drain). Bounded like the // reader park so a wedged pty can't hang the suspend. if !writer_sync.wait_drained(Duration::from_millis(750)) { tracing::warn!("suspend: frame writer not drained within 750ms; proceeding"); } // Pre-child cursor probe (minimal only — minimal's startup already proved // this terminal answers CPR). Reader is parked, so the reply is ours. let pre_cursor = screen_mode .is_minimal() .then(|| crossterm::cursor::position().ok()) .flatten(); if screen_mode.is_fullscreen() { kigi_shell::util::with_locked_stderr(|stderr| { let _ = crossterm::execute!(stderr, crossterm::terminal::LeaveAlternateScreen); }); } let _ = crossterm::terminal::disable_raw_mode(); run_child(); let _ = crossterm::terminal::enable_raw_mode(); if screen_mode.is_fullscreen() { kigi_shell::util::with_locked_stderr(|stderr| { let _ = crossterm::execute!(stderr, crossterm::terminal::EnterAlternateScreen); }); } // Discard child-exit ANSI query replies (DA/DSR/cursor reports) the terminal // buffered; reader is parked, so the main thread is the only crossterm caller. while crossterm::event::poll(Duration::from_millis(0)).unwrap_or(false) { let _ = crossterm::event::read(); } // Post-child cursor probe: `Some` iff the child left the cursor somewhere // other than where it found it (see the doc comment). let moved_cursor = pre_cursor.and_then(|pre| { let post = crossterm::cursor::position().ok()?; (post != pre).then_some(post) }); // Discard any keystroke the reader read during the brief pre-park window: it // lands in the channel, not the tty. while input_rx.try_recv().is_ok() {} input_paused.store(false, Ordering::Release); moved_cursor } /// Restore the inline/minimal live region after a tty-taking child exited. /// /// Two child behaviors, two restores (common post-suspend handling): /// /// - **Screen-restoring child** (`less` & friends: alt screen + `rmcup`, or a /// child that printed nothing): the cursor is back where it was, the main /// screen still shows the pre-suspend frame. Repaint in place — `clear()` /// resets the back buffer so the next draw rewrites every viewport cell /// (the restored screen may still differ subtly, e.g. a lost cell attribute). /// - **Inline-printing child** (`PAGER=cat`, an editor that dumps to the tty): /// its output scrolled the main screen and the cursor sits below it. The old /// viewport rows are gone (scrolled up or overwritten) — re-anchor the /// viewport at the cursor row, scrolling the screen up first when there /// isn't a full viewport of room left (the same make-room dance as the /// startup inline anchor), so the live region redraws below the child's /// output instead of overpainting it. /// /// Fullscreen mode needs neither: it re-enters the alternate screen, and /// `clear()` + redraw repaints the whole thing. fn restore_after_child( terminal: &mut PagerTerminal, screen_mode: crate::app::ScreenMode, moved_cursor: Option<(u16, u16)>, ) { use ratatui::backend::Backend as _; if let Some((_x, y)) = moved_cursor && screen_mode.is_minimal() { let screen = terminal.last_known_area(); let cur = terminal.viewport_area(); let vh = cur.height.max(1).min(screen.height.max(1)); // Newlines printed from the cursor row scroll the screen exactly when // fewer than `vh` rows remain below it (append_lines is buffered on // the frame writer, so these bytes stay ordered before the clear + // redraw below). let _ = terminal.backend_mut().append_lines(vh.saturating_sub(1)); let available = screen.height.saturating_sub(y).saturating_sub(1); let missing = vh.saturating_sub(1).saturating_sub(available); let top = y.saturating_sub(missing); terminal.set_viewport_area(ratatui::layout::Rect { y: top, height: vh, ..cur }); } let _ = terminal.clear(); } /// Consume a pending `$EDITOR` / `$PAGER` suspend request, if any. /// /// Called at the TOP of every event-loop iteration — not from one specific /// select arm — because the requests can be armed from ANY arm: a keypress /// (`$EDITOR` from the agents modal), but also an animation tick (minimal's /// incremental `/transcript` build finishes inside a tick-arm draw and arms /// `pending_pager_path`). When consumption lived only in the input arm, a /// build finishing on a tick sat armed until the next unrelated event — the /// "progress hits done, then multi-second wait before `less` opens" lag. #[allow(clippy::too_many_arguments)] fn run_pending_suspends( app: &mut AppView, terminal: &mut PagerTerminal, input_paused: &std::sync::atomic::AtomicBool, reader_parked: &std::sync::atomic::AtomicBool, input_rx: &mut tokio::sync::mpsc::UnboundedReceiver, last_draw_at: &mut Instant, draw_scheduled_at: &mut Option, ) { // $EDITOR suspend: leave alt screen, disable raw mode, spawn // editor, wait for exit, then restore. if let Some(path) = app.pending_editor_path.take() { let editor = std::env::var("VISUAL") .or_else(|_| std::env::var("EDITOR")) .unwrap_or_else(|_| "vi".to_string()); let writer_sync = terminal.backend_mut().writer_mut().writer_sync().clone(); let moved_cursor = suspend_for_child( app.screen_mode, &writer_sync, input_paused, reader_parked, input_rx, || { let _ = std::process::Command::new(&editor).arg(&path).status(); }, ); if let Some(tab) = app.pending_agents_modal_refresh.take() && let ActiveView::Agent(id) = app.active_view && let Some(agent) = app.agents.get_mut(&id) && let Some(ref mut modal) = agent.agents_modal { modal.refresh_after_editor(tab); } // The child owned the screen; re-anchor if it printed inline, and // repaint the full viewport rather than diffing against a screen // state we can no longer vouch for. restore_after_child(terminal, app.screen_mode, moved_cursor); app.draw(terminal); *last_draw_at = Instant::now(); *draw_scheduled_at = None; } // /transcript suspend: open the rendered transcript in $PAGER, // then restore and delete the temp file. Shares the editor's // suspend/restore dance (reader park, raw mode, alt screen). if let Some(path) = app.pending_pager_path.take() { let ansi = std::mem::take(&mut app.pending_pager_ansi); let pager = std::env::var("PAGER") .ok() .filter(|p| !p.trim().is_empty()) .unwrap_or_else(|| "less".to_string()); let writer_sync = terminal.backend_mut().writer_mut().writer_sync().clone(); let moved_cursor = suspend_for_child( app.screen_mode, &writer_sync, input_paused, reader_parked, input_rx, || { // $PAGER may carry flags (e.g. "less -R"); split on // whitespace so program + args are both honored. let mut parts = pager.split_whitespace(); if let Some(prog) = parts.next() { let mut args: Vec = parts.map(str::to_string).collect(); // An ANSI transcript (minimal full view) needs // `less` to interpret raw control codes, else the // colors show as literal escapes. Add `-R` when // using less and it isn't already requested. let is_less = std::path::Path::new(prog) .file_name() .and_then(|n| n.to_str()) == Some("less"); if ansi && is_less && !args.iter().any(|a| { matches!( a.as_str(), "-R" | "-r" | "--RAW-CONTROL-CHARS" | "--raw-control-chars" ) }) { args.push("-R".to_string()); } // Open the transcript at its END: minimal's prompt sits at // the bottom of the conversation, so the pager starts where // the user already is (`g` jumps back to the top). less-only // like `-R` — other $PAGERs may not understand `+G`. if ansi && is_less && !args.iter().any(|a| a == "+G") { args.push("+G".to_string()); } let _ = std::process::Command::new(prog) .args(&args) .arg(&path) .status(); } }, ); let _ = std::fs::remove_file(&path); // The pager owned the screen; re-anchor if it printed inline (cat) and // repaint the full viewport rather than diffing against a screen state // we can no longer vouch for. restore_after_child(terminal, app.screen_mode, moved_cursor); app.draw(terminal); *last_draw_at = Instant::now(); *draw_scheduled_at = None; } } /// Run the main event loop until quit. /// /// Returns a [`RunResult`] with optional exit info (for the resume hint) /// and a flag indicating whether the caller should restart the binary /// to pick up a downloaded update. /// /// The initial theme MUST come from `term_state.initial_theme`; see /// [`TerminalState::initial_theme`] for why. #[allow(clippy::too_many_arguments)] pub(crate) async fn run( terminal: &mut PagerTerminal, connection: crate::acp::AcpConnection, config_watcher: &mut ConfigWatcher, args: &PagerArgs, session_cwd: Option, remote_settings: Option, term_state: TerminalState, materialized: crate::app::session_startup::MaterializedStartup, bg_update_rx: Option< tokio::sync::oneshot::Receiver>, >, ) -> anyhow::Result { // Initialize tracing capture. The channel `rx` will be wired to a // TracingModel (and ultimately a tracing pane) once integrated. // For now we drain-and-discard in `AppView::tick()` to avoid unbounded // memory growth. if args.log_sampling { // SAFETY: called before any threads are spawned by init_tracing. unsafe { std::env::set_var("KIGI_LOG_SAMPLING", "1") }; } let tracing_handle = crate::tracing::init_tracing(); crate::unified_log::init(connection.tx.clone()); crate::unified_log::info("pager started", None, None); let mut app = AppView::new( connection.tx, connection.models, connection.available_commands, ); app.tracing_rx = Some(tracing_handle.rx); // Startup terminal height for the auto-compact derivation; kept fresh by // `Event::Resize` from here on. 0 (probe failure) never forces compact. app.last_known_terminal_rows = crossterm::terminal::size().map(|(_, r)| r).unwrap_or(0); // Leader mode: a live `leader_status_rx` means the pager is connected via a // leader. The dashboard itself is NOT gated on this flag (it renders local // sessions regardless); `leader_mode` only controls whether we additionally // poll the leader roster (see the roster-poll arm below). app.leader_mode = connection.leader_status_rx.is_some(); app.screen_mode = term_state.screen_mode; // Agent/dashboard prompts pick the mode up at their creation sites // (`apply_app_scoped_gates` / `ensure_dashboard_state`); the welcome prompt // already exists, so inject here. app.welcome_prompt.set_screen_mode(term_state.screen_mode); if app.screen_mode.is_minimal() && term_state.relaunched_into_minimal { app.minimal_state.welcome_pending = true; } if term_state.relaunched_into_minimal && app.screen_mode.is_minimal() { app.screen_mode_switch_hint = Some("Switched to minimal mode · /fullscreen to go back"); } else if term_state.relaunched_into_fullscreen && !app.screen_mode.is_minimal() { app.screen_mode_switch_hint = Some("Switched to fullscreen mode · /minimal to go back"); } let remote_permission_mode = remote_settings .as_ref() .and_then(|s| s.permission_mode.as_deref()); let launch_yolo = kigi_shell::util::config::effective_yolo_for_launch( args.yolo, args.permission_mode_flag.as_deref(), remote_permission_mode, ); app.default_yolo = launch_yolo.yolo; // Gated launch-auto (CLI `--permission-mode auto` or config). Hoisted so it can // be re-applied after `load_initial_ui_config()` replaces `current_ui` below. let launch_auto = kigi_shell::util::config::effective_auto_for_launch( args.yolo, args.permission_mode_flag.as_deref(), remote_permission_mode, ); if launch_auto { app.current_ui.permission_mode = Some("auto".into()); } // One effective-config read for launch-mode ownership + the display // resolve below (the launch resolvers above keep their own internal read). let launch_effective_ui = kigi_shell::config::load_effective_config() .ok() .and_then(|root| root.get("ui").cloned()); // Soft-default owns the mode only when neither CLI nor effective TOML // claimed it; while owned, `settings/update` pushes may re-arm it. let cli_owns_mode = args.yolo || args.permission_mode_flag.is_some(); let toml_owns_mode = launch_effective_ui .as_ref() .and_then(kigi_shell::util::config::permission_mode_from_ui_if_set) .is_some(); app.permission_mode_from_soft_default = !cli_owns_mode && !toml_owns_mode; // Cached pin snapshot gating dispatch's runtime always-approve toggles. A // mid-session pin change is missed here, but only cosmetically: the agent's // permission manager re-clamps yolo authoritatively at decision time. app.yolo_policy_block = launch_yolo.policy_block; if let Some(warning) = launch_yolo.blocked_warning { tracing::warn!("{warning}"); crate::unified_log::warn(warning, None, None); // Consumed by `switch_to_agent` once the first agent view opens. app.yolo_launch_block_notice = Some(warning); } app.require_plan_approval = kigi_shell::util::config::load_require_plan_approval(); app.plan_mode = !args.no_plan; app.subagents = !args.no_subagents; app.ask_user = !args.no_ask_user; app.chat_mode = args.chat(); app.restore_code = args.restore_code.then_some(true); if let Some(ref agent) = args.agent { match crate::headless::resolve_agent_arg(agent) { crate::headless::ResolvedAgent::FilePath(path) => { match kigi_shell::agent::config::AgentDefinition::from_file(&path) { Ok(def) => app.agent_override = Some(def.to_json_value()), Err(e) => { tracing::warn!("--agent: failed to load agent file: {e}"); } } } crate::headless::ResolvedAgent::Name(name) => { app.agent_override = Some(serde_json::Value::String(name)); } } } let headless_only: &[(&str, bool)] = &[ ("--agents", args.agents_json.is_some()), ("--tools", args.cli_tools.is_some()), ("--disallowed-tools", args.cli_disallowed_tools.is_some()), ("--max-turns", args.max_turns.is_some()), ]; for &(flag, set) in headless_only { if set { tracing::warn!("{flag} is only supported in headless mode (-p); ignored in TUI"); } } tracing::info!( cli_restore_code = args.restore_code, mapped_restore_code = ?app.restore_code, worktree = ?args.worktree, resume = ?args.resume_session, "RESTORE_CODE_DEBUG: CLI args mapped" ); app.cli_model_override = args .model .as_deref() .map(agent_client_protocol::ModelId::new); app.cli_effort_token = args.reasoning_effort.clone(); app.auth_use_oauth = args.oauth; app.show_resolved_model = remote_settings .as_ref() .and_then(|s| s.show_resolved_model) .unwrap_or(true); app.session_picker_grouped = std::env::var("KIGI_SESSION_PICKER_GROUPED") .ok() .and_then(|v| match v.as_str() { "1" | "true" => Some(true), "0" | "false" => Some(false), _ => None, }) .or_else(|| { kigi_shell::config::load_effective_config() .ok() .and_then(|cfg| cfg.get("cli")?.get("session_picker_grouped")?.as_bool()) }) .or_else(|| { remote_settings .as_ref() .and_then(|s| s.session_picker_grouped) }) .unwrap_or(true); app.cancel_rewind_enabled = connection.cancel_rewind_enabled; apply_session_recap_available(&mut app, connection.session_recap_available); // Preserve auth methods so logout→re-login works without restarting. app.auth_methods = connection.auth_methods.clone(); // Seed auth state from ACP connection metadata. // --force-login overrides: show the login screen even when credentials exist. let force_login = args.force_login && !connection.auth_methods.is_empty(); let needs_interactive_login = connection.needs_login || force_login; if needs_interactive_login { app.welcome_prompt_focused = false; if connection.needs_login { // Normal path: use the metadata from startup_auth_metadata() app.login_label = connection.login_label; app.login_method_id = connection.login_method_id; app.auth_start_mode = match connection.auth_start_mode { crate::acp::AuthStartMode::Pending => super::app_view::AuthMode::Pending, crate::acp::AuthStartMode::Command => super::app_view::AuthMode::Command, }; } else { // --force-login: find the kimi.com method from the advertised list let kigi_com = connection .auth_methods .iter() .find(|m| m.id().0.as_ref() == "kimi-code"); if let Some(method) = kigi_com { app.login_label = Some(method.name().to_string()); app.login_method_id = Some(method.id().clone()); let is_provider = method .meta() .as_ref() .and_then(|v| v.get("external_provider")) .and_then(|v| v.as_bool()) .unwrap_or(false); app.auth_start_mode = if is_provider { super::app_view::AuthMode::Command } else { super::app_view::AuthMode::Pending }; } else { // No kimi.com method available, use the first method as fallback let first = &connection.auth_methods[0]; app.login_label = Some(first.name().to_string()); app.login_method_id = Some(first.id().clone()); app.auth_start_mode = super::app_view::AuthMode::Pending; } } // Skip the login splash screen — auto-trigger login immediately // by reusing dispatch_login. Effects are stashed and drained after // the initial render so the user sees the auth UI right away. // Empty auth_methods (preferred_method pin with no credentials) is // fail-closed: do not invent kimi.com / auto-start OIDC. tracing::info!( method_id = ?app.login_method_id, methods_empty = connection.auth_methods.is_empty(), "auto-triggering login at startup" ); } // else: auth_state defaults to Done (already authenticated eagerly) // Effects stashed until after the initial render, so the user sees the // welcome/auth UI right away. let post_render_effects = if needs_interactive_login { if connection.auth_methods.is_empty() { app.auth_state = super::app_view::AuthState::Pending { error: Some("No login method available".to_string()), }; vec![] } else if super::app_view::login_picker_has_choice(&app.auth_methods) { // Multiple interactive login choices (OAuth + Moonshot API-key // rows): land on the login picker instead of auto-starting the // device flow, so the user can choose a platform. Single-choice // shells keep the historical auto-trigger below. app.auth_state = super::app_view::AuthState::Pending { error: None }; vec![] } else { dispatch::dispatch(Action::Login, &mut app) } } else { vec![] }; if let Some(meta) = connection.auth_meta.as_ref() { match serde_json::from_value::(meta.clone()) { Ok(auth_meta) => app.apply_auth_meta(&auth_meta), Err(e) => tracing::warn!("failed to deserialize auth_meta: {e}"), } } else { // No cached session — check if the API key is the active credential. app.is_api_key_auth = app .auth_methods .iter() .any(|m| m.id().0.as_ref() == kigi_shell::agent::auth_method::XAI_API_KEY_METHOD_ID); // No AuthMeta on this path — hide `/usage` for API keys. if app.is_api_key_auth { app.usage_visible = false; } } // Load config layers once, resolve tips and feature flags. let requirements = kigi_shell::config::load_merged_requirements(); let user_config = kigi_shell::config::load_from_disk().ok(); let managed_config = kigi_shell::config::load_managed_config().ok(); // Full merge when every layer parses; partial merge below if any layer fails. let effective_config = match kigi_shell::config::load_effective_config() { Ok(raw) => Some(raw), Err(e) => { tracing::debug!(error = %e, "failed to load effective config, using partial layers"); None } }; let compat = kigi_shell::agent::config::resolve_compat_sessions_from_raw( effective_config.as_ref().ok_or(()), remote_settings.as_ref(), ); app.foreign_session_compat = kigi_workspace::foreign_sessions::EnabledForeignSessionSources { claude: compat.claude.sessions, codex: compat.codex.sessions, cursor: compat.cursor.sessions, }; // Load notification config from [ui.notifications] in config.toml. if let Some(ref raw) = effective_config { app.notification_service = crate::notifications::NotificationService::new( crate::notifications::load_notification_config(raw), ); } // Full layered resolve (env/requirements/remote may beat plain `[ui]`). crate::appearance::cache::set_show_thinking_blocks( kigi_shell::util::config::resolve_show_thinking_blocks( requirements.as_ref(), user_config.as_ref(), managed_config.as_ref(), remote_settings.as_ref(), ) .value, ); crate::appearance::cache::set_group_tool_verbs( kigi_shell::util::config::resolve_group_tool_verbs( requirements.as_ref(), user_config.as_ref(), managed_config.as_ref(), remote_settings.as_ref(), ) .value, ); crate::appearance::cache::set_collapsed_edit_blocks( kigi_shell::util::config::resolve_collapsed_edit_blocks( requirements.as_ref(), user_config.as_ref(), managed_config.as_ref(), remote_settings.as_ref(), ) .value, ); { use kigi_shell::util::config::resolve_tips; let remote_tips = remote_settings.as_ref().and_then(|s| s.tips.as_deref()); app.tips = resolve_tips( requirements.as_ref(), user_config.as_ref(), managed_config.as_ref(), remote_tips, ); if !app.tips.is_empty() { let kigi_home = kigi_tools::util::kigi_home::kigi_home(); app.tip = kigi_shell::util::tips::pick_and_advance(&app.tips, &kigi_home); } } let hints = kigi_shell::util::config::resolve_hints( effective_config.as_ref(), requirements.as_ref(), user_config.as_ref(), managed_config.as_ref(), ); app.project_picker_disabled = hints.project_picker_disabled; // Per-tip contextual hints resolve from `[ui.contextual_hints]` (loaded into // `app.current_ui` further below) + the remote tier; the resolve + prompt // propagation happen after `current_ui` is hydrated. app.remote_contextual_hints = remote_settings .as_ref() .and_then(|s| s.contextual_hints.clone()); app.new_session_worktree_mode = hints.new_session_worktree_mode.into(); app.fork_worktree_mode = hints.fork_worktree_mode.into(); // Ephemeral-tip seen counts are intentionally NOT hydrated: the cap is // per-session (in-memory `app.tip_seen_counts`), so each run starts fresh. // Cache whether cwd is inside a git repo (avoids repeated stat() in draw). app.cwd_has_git_ancestor = app.cwd.ancestors().any(|p| p.join(".git").exists()); // Probe / auto-cadence / terminal telemetry — see `display_refresh_startup`. let motion = super::display_refresh_startup::start( requirements.as_ref(), user_config.as_ref(), managed_config.as_ref(), remote_settings.as_ref(), ); let min_draw_interval = motion.min_draw_interval; let scroll_cadence = motion.scroll_cadence; // Collect structured startup warnings from the terminal diagnostics engine. // These are stored on AppView and rendered as a dismissible in-app banner // when the user enters an agent session. { let ctx = crate::terminal::terminal_context(); let query = crate::diagnostics::LiveTmuxQuery; let mut warnings = crate::diagnostics::collect_startup_warnings( ctx, &query, term_state.is_control_mode, term_state.screen_mode.is_fullscreen(), ); // Wayland no-data-control reads the live environment, so it rides its // own wrapper (keeps `collect_startup_warnings` hermetic for tests). warnings.extend(crate::diagnostics::diagnose_wayland_data_control_live()); let notif_warnings = crate::diagnostics::collect_notification_warnings( ctx, app.notification_service.protocol(), app.notification_service.config().condition, &query, ); // Deduplicate by category: general terminal warnings take priority // over notification-specific ones (e.g. DcsPassthrough can fire from // both sources when allow-passthrough is off). let mut seen = std::collections::HashSet::new(); for w in &warnings { seen.insert(w.category); } let mut all_warnings = warnings; all_warnings.extend( notif_warnings .into_iter() .filter(|w| seen.insert(w.category)), ); if !all_warnings.is_empty() { tracing::info!("Collected {} startup warnings", all_warnings.len()); } // WezTerm without the Kitty keyboard protocol breaks local input // (Shift+Enter can't insert newlines), so its banner is surfaced // directly (no SSH gate) and first — see `assemble_startup_warnings`. // `xtversion::detected()` is structurally `None` here (the probe is // only sent further down, right before the input reader thread is // spawned), so this banner covers env-detected WezTerm; the SSH shape // surfaces in /terminal-setup once the async reply has landed. let wezterm_warning = crate::diagnostics::wezterm_kitty_keyboard_warning( ctx, crate::app::kitty_flags_pushed(), crate::terminal::xtversion::detected(), ); // Wayland no-data-control is surfaced without the SSH gate of // `summarize_warnings` — the broken shape is local (see // `assemble_startup_warnings`). let wayland_clipboard_warning = all_warnings .iter() .find(|w| w.category == crate::diagnostics::WarningCategory::WaylandNoDataControl); let sandbox_profile_warning = crate::diagnostics::sandbox_profile_conflict_warning(&app.cwd); app.startup_warnings = crate::diagnostics::assemble_startup_warnings( wezterm_warning.as_ref(), wayland_clipboard_warning, sandbox_profile_warning.as_ref(), crate::diagnostics::summarize_warnings(&all_warnings) .into_iter() .collect(), ); } // F7: one-time offer to import the official kimi-cli configuration. // Parallel to the Claude-import detection (`has_claude_import`), but // deliberately lightweight: a welcome-screen system line pointing at the // CLI command instead of a modal. Disappears once `kigi import-kimi` has // run (marker file under ~/.kigi); the scan is read-only over ~/.kimi. if kigi_shell::kimi_import::has_pending_kimi_import() { app.startup_warnings.push(crate::startup::StartupWarning { severity: crate::startup::WarningSeverity::Info, message: "Found official kimi-cli settings in ~/.kimi.".to_string(), action: Some("Run `kigi import-kimi` to import them (one-time).".to_string()), }); } // Apply initial config (may come from existing ~/.kigi/pager.toml). let mut initial_config = config_watcher.current().clone(); // The cache holds the USER compact value; the render value is derived // (auto-compact while the startup terminal is short). initial_config.prompt.compact = crate::views::agent::effective_compact( crate::appearance::cache::load(), app.last_known_terminal_rows, ); initial_config.show_timestamps = crate::appearance::cache::load_timestamps(); initial_config.show_timeline = crate::appearance::cache::load_show_timeline(); let tick_interval = initial_config.animation.tick_interval(); crate::appearance::set_tab_width(initial_config.scrollback.display.tab_width); app.set_appearance(initial_config); // Seed app state from disk once at the I/O boundary so dispatch // stays sans-IO. app.current_ui = load_initial_ui_config(); // Field-tolerant: a whole-`UiConfig` default (malformed unrelated `[ui]` // field) must not wipe a valid `show_timeline` or leave appearance / // cache / `current_ui` disagreeing — `/timeline` and the rail all read // the same canonical value after this sync + `prime` below. let show_timeline = crate::appearance::cache::load_show_timeline(); app.current_ui.show_timeline = Some(show_timeline); if app.appearance.show_timeline != show_timeline { let mut config = app.appearance.clone(); config.show_timeline = show_timeline; app.set_appearance(config); } // Disk load replaces `current_ui`. Assign one policy-clamped resolved // launch mode unconditionally (CLI > TOML > remote > Ask) so disk Auto // cannot win over `--permission-mode ask`, and a policy-clamped remote // AlwaysApprove cannot leave the UI claiming AlwaysApprove while // enforcement is Ask. let display_mode: &'static str = if launch_auto { "auto" } else if launch_yolo.yolo { "always-approve" } else if let Some(cli) = args.permission_mode_flag.as_deref() { // CLI always-approve/auto that did not become launch_yolo/launch_auto // (policy pin / gate) display as Ask. kigi_shell::util::config::clamped_display_permission_mode( kigi_shell::util::config::parse_permission_mode_canonical(cli), ) } else { kigi_shell::util::config::resolved_display_permission_mode( launch_effective_ui.as_ref(), remote_permission_mode, ) }; app.current_ui.permission_mode = Some(display_mode.to_string()); super::dispatch::downgrade_displayed_auto_if_gated(&mut app); // Seed `/auto` feature-gate visibility from the resolved gate (so `/auto` // is offered on the welcome prompt when available). app.sync_permission_mode_slash_gate(); // Resolve the per-tip contextual hints now that `current_ui` is hydrated and // propagate the prompt-relevant tips to any agents built at startup. New // agents adopt the gates at creation; settings toggles re-apply at runtime. let resolved_hints = kigi_shell::util::config::resolve_contextual_hints( &app.current_ui.contextual_hints, app.remote_contextual_hints.as_ref(), ); app.apply_contextual_hints(resolved_hints); // Opt-in mouse-reporting toggle shortcut (Ctrl+R on scrollback). Off unless // explicitly enabled. Resolved in shell config (env override > effective // config > the parsed `UiConfig` field) so a partial `UiConfig` deserialize // failure cannot silently drop it. let mouse_toggle = kigi_shell::util::config::resolve_mouse_reporting_toggle( effective_config.as_ref(), &app.current_ui, ); app.registry = crate::actions::ActionRegistry::defaults_with_config(mouse_toggle.value); // Cache the resolved flag so the `/toggle-mouse-reporting` slash command can // gate its visibility/execution without re-reading config on every keystroke. crate::app::MOUSE_REPORTING_TOGGLE_ENABLED .store(mouse_toggle.value, std::sync::atomic::Ordering::Release); let action_registered = app .registry .find(crate::actions::ActionId::ToggleMouseCapture) .is_some(); crate::unified_log::info( "mouse_reporting_toggle.startup", None, Some(serde_json::json!({ "enabled": mouse_toggle.value, "source": mouse_toggle.source.to_string(), "ui_config_field": app.current_ui.mouse_reporting_toggle, "action_registered": action_registered, "shortcut": "Ctrl+R", "context": "scrollback_focused_only", "slash_command": "/toggle-mouse-reporting", "note": "the toggle chord is scrollback-only; press Tab to focus scrollback first, or use /toggle-mouse-reporting from anywhere", })), ); let config_session_bools = load_initial_config_session_bools(); app.show_tips = config_session_bools.show_tips; app.auto_update = config_session_bools.auto_update; app.ask_user_question_timeout_enabled = config_session_bools.ask_user_question_timeout_enabled; // Prime thread-local caches so first render doesn't hit disk. crate::appearance::cache::prime(&app.current_ui); // Re-derive the render-value compact flag from the hydrated `current_ui`: // the seed above used the pre-hydration disk read, which layered/remote // config can contradict — the canonical single-writer corrects it (and // fans out to any startup agents) before the first draw. app.apply_effective_compact(); // Apply the scroll settings from the caches (seeded by `prime` above; // KIGI_SCROLL_SPEED/_MODE/_LINES + KIGI_INVERT_SCROLL env overrides // apply on first load). app.scroll_config = crate::input::mouse::ScrollConfig::from_settings(); // Fire-and-forget XTVERSION query; must sit immediately before the input // reader thread is spawned so no earlier stdin consumer eats the reply. crate::terminal::xtversion::probe_at_startup(); // Read terminal events on a dedicated thread and forward them over an mpsc // channel. The main `select!` consumes via `input_rx.recv()`, which is // cancellation-safe: when another arm wins, the recv future is dropped and // re-created without losing the wakeup. Polling crossterm's `EventStream` // directly in the select is NOT safe -- dropping its `next()` future // mid-poll (a losing arm) strands its background waker (crossterm #936), so // input on an idle screen was not serviced until an unrelated arm happened // to re-poll (every ~20s via recap_poll). The always-on tracing_rx tick // used to mask this by re-polling ~30Hz; this removes that dependency. let (input_tx, mut input_rx) = tokio::sync::mpsc::unbounded_channel::(); // Set true around tty handoffs (e.g. $EDITOR) so the reader stops touching // stdin and the inheriting child process keeps every keystroke. let input_paused = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let reader_paused = input_paused.clone(); // Set by the reader once it has parked (stopped calling crossterm) so the // $EDITOR handoff can wait for it: poll/read share one global lock, so the // main-thread drain must be the sole crossterm caller. let reader_parked = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let reader_parked_thread = reader_parked.clone(); std::thread::spawn(move || { use std::sync::atomic::Ordering; // Short enough that a pause / receiver-drop is observed promptly, long // enough to keep the thread parked when idle. A `poll()` timeout here // does NOT wake the main loop -- only a successful `send` does -- so the // idle event loop still parks (no reintroduced metronome tick). const POLL_TIMEOUT: Duration = Duration::from_millis(100); let mut consecutive_event_errors: u32 = 0; loop { // Shutdown observed within one poll cycle in every state (idle or // paused); the send() break below covers close-while-sending. if input_tx.is_closed() { break; } // While a tty handoff owns stdin, do not read(): the child (e.g. the // editor) must keep its bytes. Re-check soon without touching stdin. if reader_paused.load(Ordering::Acquire) { // Signal the handoff that the reader is no longer in crossterm. reader_parked_thread.store(true, Ordering::Release); std::thread::sleep(POLL_TIMEOUT); continue; } // Active path: this thread owns crossterm again this iteration. reader_parked_thread.store(false, Ordering::Release); // poll()+read() (not a bare blocking read) so the pause flag and a // dropped receiver are observed within POLL_TIMEOUT. let event = match crossterm::event::poll(POLL_TIMEOUT) { Ok(true) => crossterm::event::read(), Ok(false) => continue, Err(e) => Err(e), }; match event { Ok(ev) => { consecutive_event_errors = 0; if input_tx.send(ev).is_err() { break; // event loop has shut down } } Err(e) => { // VTE terminals / SSH PTYs can emit garbage that crossterm's // parser rejects; skip transient errors rather than kill the // TUI (ratatui#1275), bailing only if they never stop. consecutive_event_errors += 1; if consecutive_event_errors >= 50 { tracing::error!( "crossterm read returned {consecutive_event_errors} \ consecutive errors, exiting reader: {e}" ); break; } tracing::warn!("crossterm read error (skipping): {e}"); } } } }); let mut acp_rx = connection.rx; let connection_cancel = connection.cancel; let mut leader_status_rx = connection.leader_status_rx; let mut tasks: JoinSet = JoinSet::new(); let (progress_tx, mut progress_rx) = tokio::sync::mpsc::unbounded_channel::(); // Animation tick: only scheduled when there are running entries. let mut tick_interval = tick_interval; let mut animation_tick_at: Option = None; // Whether the extra Kitty keyboard layer (WASD release events) is // currently pushed for the /gboom game. Synced to `gboom_active` each // iteration so it is popped on every close path. let mut gboom_keyboard_pushed = false; // Leader-mode roster poll (FleetView dashboard). Only fires while the // dashboard is open AND we're connected via a leader. Armed to fire // immediately at loop start so an already-open dashboard refreshes // without waiting a full interval. const ROSTER_POLL_INTERVAL: Duration = Duration::from_secs(1); let mut roster_poll_at: Option = Some(Instant::now()); // Pre-generate the automatic "return-from-away" recap while the terminal is // unfocused, so it's already in the scrollback (instant) when the user // returns. The arm is a cheap no-op while focused / not-yet-eligible; the // heavy lifting (the model call) only fires once per away period via // `should_pregenerate_away_recap`. const RECAP_POLL_INTERVAL: Duration = Duration::from_secs(20); let mut recap_poll_at: Option = Some(Instant::now() + RECAP_POLL_INTERVAL); // Seed the folder-trust verdict BEFORE the first render and before any // session is created (no repo-local MCP/LSP/hooks/plugins have loaded yet). // Feature-off (kill-switch / opt-out / local build) resolves `Trusted`, so // this stays `TrustState::Done`. seed_trust_state(&mut app, remote_settings.as_ref()); // Initial render app.draw(terminal); // status only; shell auto-syncs post-auth if matches!(app.auth_state, AuthState::Done) { let effs = dispatch::dispatch(Action::RequestBundleStatus, &mut app); if process_effects(effs, &mut tasks, &mut app, &progress_tx) { return Ok(make_run_result(&app)); } } if !post_render_effects.is_empty() && process_effects(post_render_effects, &mut tasks, &mut app, &progress_tx) { return Ok(make_run_result(&app)); } // Session startup from pre-materialized CLI intent. // These actions are dispatched UNCONDITIONALLY: the session-creating // chokepoints self-gate when auth + folder trust is closed. use crate::app::session_startup::MaterializedStartup; let startup_action = match &materialized { MaterializedStartup::Resume { session_id, .. } if args.worktree.is_some() => { tracing::info!( session_id, restore_code = ?app.restore_code, "RESTORE_CODE_DEBUG: worktree+resume path taken" ); Some(Action::NewWorktreeSession { load_session_id: Some(session_id.clone()), label: args.worktree.as_ref().filter(|s| !s.is_empty()).cloned(), git_ref: args.worktree_ref.clone(), }) } MaterializedStartup::Resume { session_id, .. } => { // CLI resume has no roster entry: `chat_kind` on LoadSession is the // conversation-entry bit only (false here). Process-wide `--chat` // still stamps kind=chat via SessionFlags.chat_mode in the load // effect; local Build disk rows are refused in dispatch / startup. Some(Action::LoadSession( session_id.clone(), session_cwd.clone(), false, )) } MaterializedStartup::NewWithId { session_id } if args.worktree.is_some() => { // Stash preferred id; `dispatch_new_worktree_session` consumes it and // passes through `CreateWorktreeSession.preferred_session_id` so the // worktree + ACP session use the CLI-chosen id (not an auto `pager-*`). app.deferred_startup.preferred_session_id = Some(session_id.clone()); Some(Action::NewWorktreeSession { load_session_id: None, label: args.worktree.as_ref().filter(|s| !s.is_empty()).cloned(), git_ref: args.worktree_ref.clone(), }) } MaterializedStartup::NewWithId { session_id } => { Some(Action::NewSessionWithId(session_id.clone())) } MaterializedStartup::Fork { parent_session_id, parent_cwd, new_session_id, .. } => Some(Action::StartupForkSession { parent_session_id: parent_session_id.clone(), parent_cwd: parent_cwd.clone().or(session_cwd.clone()), new_session_id: new_session_id.clone(), }), MaterializedStartup::NewAuto if args.worktree.is_some() => { Some(Action::NewWorktreeSession { load_session_id: None, label: args.worktree.as_ref().filter(|s| !s.is_empty()).cloned(), git_ref: args.worktree_ref.clone(), }) } MaterializedStartup::NewAuto => None, }; if let Some(action) = startup_action { let effs = dispatch::dispatch(action, &mut app); if process_effects(effs, &mut tasks, &mut app, &progress_tx) { return Ok(make_run_result(&app)); } app.draw(terminal); } else if args.worktree.is_some() { // --worktree only: create worktree + new session. let effs = dispatch::dispatch( Action::NewWorktreeSession { load_session_id: None, label: args.worktree.as_ref().filter(|s| !s.is_empty()).cloned(), git_ref: args.worktree_ref.clone(), }, &mut app, ); if process_effects(effs, &mut tasks, &mut app, &progress_tx) { return Ok(make_run_result(&app)); } app.draw(terminal); } // Initial prompt from the CLI positional (`kigi "fix the bug"`). When // already authenticated, hand it to the shared dispatcher helper (same // `NewSession`/`SendPrompt` path the welcome screen uses). When not yet // authenticated, stash it for `AuthComplete`. if let Some(initial_prompt) = args.initial_prompt() { if !app.session_startup_allowed() { app.deferred_startup.prompt = Some(initial_prompt.to_string()); } else { let effs = dispatch::dispatch_initial_prompt(&mut app, initial_prompt.to_string()); if process_effects(effs, &mut tasks, &mut app, &progress_tx) { return Ok(make_run_result(&app)); } app.draw(terminal); } } // `kigi dashboard` startup: open the dashboard view immediately. The // CLI subcommand wrote a `KIGI_OPEN_DASHBOARD_AT_STARTUP=1` env var // so we don't have to thread a flag through every arg struct. if std::env::var("KIGI_OPEN_DASHBOARD_AT_STARTUP").as_deref() == Ok("1") { // SAFETY: we are pre-multithreaded init for this app loop. unsafe { std::env::remove_var("KIGI_OPEN_DASHBOARD_AT_STARTUP") }; if app.session_startup_allowed() { let effs = dispatch::dispatch(Action::OpenDashboard, &mut app); if process_effects(effs, &mut tasks, &mut app, &progress_tx) { return Ok(make_run_result(&app)); } app.draw(terminal); } else { // Not signed in yet — the env var is already consumed, so // without a stash the request would be silently dropped and // the post-login flow would land on the welcome screen. // Defer to the `AuthComplete` handler (mirrors // the deferred session/prompt owner). app.deferred_startup.open_dashboard = true; } } // Minimal (scrollback-native) mode has no welcome screen: the live region // only renders for an Agent view. If nothing above already started a // session (no resume / initial prompt / worktree / dashboard), open an // empty one so the user lands directly at the prompt. Unauthenticated / // ZDR-blocked startup stays on Welcome, where `crate::minimal::live` shows // a sign-in hint instead of a blank region. if term_state.screen_mode.is_minimal() && matches!(app.active_view, ActiveView::Welcome) { if app.session_startup_allowed() { // Already authenticated + trusted: open the empty session now so the // user lands directly at the prompt. let effs = dispatch::dispatch(Action::NewSession, &mut app); if process_effects(effs, &mut tasks, &mut app, &progress_tx) { return Ok(make_run_result(&app)); } app.draw(terminal); } else { // Sign-in (or folder-trust) still pending: minimal renders the // device / external sign-in flow in its live region. Defer the // empty-session creation so the post-auth (or post-trust) drain // (`drain_startup_actions`) opens it — otherwise minimal would // authenticate but never create a session, stranding the user on the // sign-in screen. app.deferred_startup.new_session = true; } } // Startup intents are now fully classified; only an untouched welcome can nudge. if let Some(effect) = app.begin_foreign_resume_detection() && process_effects(vec![effect], &mut tasks, &mut app, &progress_tx) { return Ok(make_run_result(&app)); } // Schedule the first animation tick so live updates start immediately // (without waiting for user input). schedule_tick(&mut animation_tick_at, &app, tick_interval); // Resize debounce: during continuous terminal drags, dozens of resize // events fire per second. Each would trigger a full layout rebuild of all // entries (the most expensive per-frame operation). Instead of drawing on // every resize, we schedule a single deferred draw after the size stabilizes. const RESIZE_DEBOUNCE: Duration = Duration::from_millis(16); let mut resize_debounce_at: Option = None; // Cadences resolved once above (env > auto > 16ms). AppView/Default stays hermetic. app.scroll_state.set_redraw_cadence(scroll_cadence); // ACP batch bound: large enough to keep the hundreds-buffered streaming // case batched (draws stay cadence-throttled regardless), small enough that // loop-top work (suspends, deadline re-derivation) never waits on an // unbounded drain during a token firehose. const ACP_DRAIN_BATCH_MAX: usize = 32; let mut last_draw_at = Instant::now(); let mut draw_scheduled_at: Option = None; let mut reconnect_reinit: Option = None; let mut reconnect_abort_handle: Option = None; // Highest `Connected` generation already handled. Starts at 0 — the // initial pre-reconnect watch value — so startup never triggers a reload; // any greater generation is a reconnect, even when the intermediate // `Reconnecting` state was coalesced away by the watch channel. let mut last_leader_generation: u64 = 0; // Persistent CSI fragment filter — carries parsing state across // drain_and_process calls so a mouse report split across batches is still // caught; a focus report is only swallowed when its `\e` and `[I`/`[O` // land in the same batch. let mut csi_filter = super::csi_filter::CsiFragmentFilter::new(); // Swallows the fire-and-forget XTVERSION reply whenever it arrives; // armed only when the startup query is still unanswered. let mut xt_filter = super::xt_filter::XtversionFilter::new(); // Background update check: resolves when the spawned update task // determines whether a newer version is available. let mut bg_update_rx = bg_update_rx; // `app::run` publishes the resolved theme into `theme_cache::CURRENT` // before `init_terminal` so `apply_cursor_color()` sees it. Pin the // invariant so a future refactor that drops the `theme_cache::set` call // fails loudly in debug builds rather than silently regressing the // initial cursor color. debug_assert_eq!(term_state.initial_theme, theme_cache::current_kind()); let mut appearance_watcher = SystemAppearanceWatcher::start_if_auto(theme_cache::is_auto_mode()); // Registered so the signal handler can request a graceful quit; see signal_handler. let quit_notify = std::sync::Arc::new(tokio::sync::Notify::new()); crate::app::signal_handler::set_quit_notify(quit_notify.clone()); loop { // Pending $EDITOR / $PAGER suspends first: they can be armed by ANY // arm of the select below (input, ticks — e.g. minimal's incremental // /transcript build finishing inside a tick draw — tasks, ACP), so // consuming them here keeps the handoff immediate instead of waiting // for the next unrelated event. run_pending_suspends( &mut app, terminal, &input_paused, &reader_parked, &mut input_rx, &mut last_draw_at, &mut draw_scheduled_at, ); // Keep the /gboom keyboard layer in sync with whether the game is // open, so WASD emit releases while it runs and the layer is popped // on every close path (Esc, game-over dismiss, session switch). let want_gboom_keyboard = app.gboom_active(); if want_gboom_keyboard { if !gboom_keyboard_pushed { super::push_gboom_keyboard_flags(); gboom_keyboard_pushed = true; } // Only the active game receives release events; any other open // game must drop its latched holds, or it resumes walking with // no key down when reopened after a tab/view switch. app.gboom_release_backgrounded_games(); } else if gboom_keyboard_pushed { super::pop_gboom_keyboard_flags(); gboom_keyboard_pushed = false; // No game is the active input target now (switched to a non-game // view); clear every game's holds for the same reason. app.gboom_release_all_games(); } // Re-arm the dashboard roster poll when the dashboard is open but the // poll has gone dormant — i.e. the dashboard was just opened. The poll // arm leaves `roster_poll_at = None` only when it fired with the // dashboard closed, so this fires an immediate refresh exactly on the // closed→open transition rather than every iteration. Applies in both // modes: leader mode polls the live roster, non-leader mode polls the // local on-disk idle-session list. if roster_poll_at.is_none() && matches!(app.active_view, ActiveView::AgentDashboard) { roster_poll_at = Some(Instant::now()); } // Future that sleeps until the next animation tick, or waits forever if none. let animation_tick = async { match animation_tick_at { Some(at) => sleep_until(at).await, None => std::future::pending().await, } }; // Dedicated scroll clock, derived fresh each iteration — a pure // function of scroll state, so no arm can forget to reschedule it. // Armed only while a wheel/trackpad stream is active, at the state // machine's own deadline (16ms cadence flushes while lines are // pending, the 80ms stream-gap finalize otherwise): scroll pacing // must never ride the slower animation fps, which turned residual // flushes into visible jumps. let scroll_tick_at = { let now = Instant::now(); app.scroll_state .scroll_clock_deadline(now.into_std()) .map(|delay| now + delay) }; // Future that sleeps until the scroll deadline, or waits forever. let scroll_tick = async { match scroll_tick_at { Some(at) => sleep_until(at).await, None => std::future::pending().await, } }; // Future that sleeps until the resize debounce fires, or waits forever. let resize_debounce = async { match resize_debounce_at { Some(at) => sleep_until(at).await, None => std::future::pending().await, } }; // Future that sleeps until a throttled draw fires, or waits forever. let deferred_draw = async { match draw_scheduled_at { Some(at) => sleep_until(at).await, None => std::future::pending().await, } }; let roster_poll = async { match roster_poll_at { Some(at) => sleep_until(at).await, None => std::future::pending().await, } }; let recap_poll = async { match recap_poll_at { Some(at) => sleep_until(at).await, None => std::future::pending().await, } }; tokio::select! { biased; // Leader disconnect: the bridge fires cancel when the IPC // channel closes. Without this arm the loop would hang // because AppView holds the client-side tx, keeping acp_rx open. _ = connection_cancel.cancelled() => { break; } // Graceful-quit request from the signal handler. Kept high in the // biased order so a SIGTERM quit isn't starved by an ACP firehose. _ = quit_notify.notified() => { let effs = dispatch::dispatch(Action::Quit, &mut app); let _ = process_effects(effs, &mut tasks, &mut app, &progress_tx); break; } // Gated on empty terminal input: a token firehose keeps this arm // ready at every biased poll, so without the gate buffered // wheel/key events sat in input_rx until the stream went quiet. // Safe: whenever the gate disables this arm, the input arm below // is immediately ready, and it drains its whole backlog per // iteration, so ACP resumes on the next loop (no reverse starve). // Gating, not reordering: moving input above ACP would flip the // starvation direction (streaming redraws starving behind held // keys), and cancel/quit must stay above the firehose regardless. msg = acp_rx.recv(), if input_rx.is_empty() => { let Some(msg) = msg else { break }; let mut state_changed = acp_handler::handle(msg, &mut app); if !app.pending_effects.is_empty() { let effs = std::mem::take(&mut app.pending_effects); if process_effects(effs, &mut tasks, &mut app, &progress_tx) { break; } } // Drain immediately-ready ACP messages before drawing. // During streaming, dozens of messages queue per frame; // batching avoids per-message draws that starve terminal input. // Bounded, and cut short the moment input arrives, so wheel/key // events wait at most one batch — never a whole token flood. // Starts at 1: the recv() above consumed this batch's first message. let mut drained = 1; while drained < ACP_DRAIN_BATCH_MAX && input_rx.is_empty() { let Ok(msg) = acp_rx.try_recv() else { break }; drained += 1; state_changed |= acp_handler::handle(msg, &mut app); if !app.pending_effects.is_empty() { let effs = std::mem::take(&mut app.pending_effects); if process_effects(effs, &mut tasks, &mut app, &progress_tx) { return Ok(make_run_result(&app)); } } } if state_changed { schedule_tick(&mut animation_tick_at, &app, tick_interval); resize_debounce_at = None; // Cap paint rate so terminal input isn't starved during // heavy ACP streaming. let now = Instant::now(); if now.duration_since(last_draw_at) >= min_draw_interval { app.update_notifications(); app.draw(terminal); last_draw_at = now; draw_scheduled_at = None; } else if draw_scheduled_at.is_none() { draw_scheduled_at = Some(last_draw_at + min_draw_interval); } } } Some(join_result) = tasks.join_next() => { match join_result { Ok(result) => { let effs = dispatch::dispatch(Action::TaskComplete(result), &mut app); if process_effects(effs, &mut tasks, &mut app, &progress_tx) { break; } schedule_tick(&mut animation_tick_at, &app, tick_interval); resize_debounce_at = None; app.draw(terminal); last_draw_at = Instant::now(); draw_scheduled_at = None; } Err(join_err) => { // Task was aborted (e.g., auth cancel) or panicked. if join_err.is_cancelled() { tracing::debug!("Spawned task was cancelled (aborted)"); } else { tracing::error!("Spawned task panicked: {join_err}"); } } } } Some(msg) = progress_rx.recv() => { let result = TaskResult::SessionRestoreProgress { agent_id: msg.agent_id, message: msg.message, }; let effs = dispatch::dispatch(Action::TaskComplete(result), &mut app); if process_effects(effs, &mut tasks, &mut app, &progress_tx) { break; } app.draw(terminal); last_draw_at = Instant::now(); draw_scheduled_at = None; } // Background update check completed. result = async { match bg_update_rx.as_mut() { Some(rx) => rx.await.ok().flatten(), None => std::future::pending().await, } } => { // Consume the receiver so this arm becomes inert. bg_update_rx = None; if let Some(update) = result { tracing::info!( latest_version = %update.latest_version, "Background update check: newer version available" ); let latest = update.latest_version; app.pending_update_version = Some(latest.clone()); // The full TUI surfaces this on the welcome screen, which // minimal has none of — commit a one-line notice into // native scrollback instead (update notice). if term_state.screen_mode.is_minimal() { dispatch::commit_minimal_update_notice(&mut app, &latest); } app.draw(terminal); last_draw_at = Instant::now(); draw_scheduled_at = None; } } maybe_ev = input_rx.recv() => { // Terminal events arrive via the dedicated reader thread set up // near the top of this function. `None` means that thread ended. let Some(ev) = maybe_ev else { break }; let result = drain_and_process( ev, &mut input_rx, &mut app, &mut tasks, &progress_tx, &mut csi_filter, &mut xt_filter, ).await; if result.should_quit { break; } if !app.pending_effects.is_empty() { let effs = std::mem::take(&mut app.pending_effects); if process_effects(effs, &mut tasks, &mut app, &progress_tx) { break; } } // Opportunistic clipboard-image poll: this iteration already ran // for input / FocusGained / resize, so ride it (throttled, // changeCount-first). Never scheduled by a timer — an idle app // polls zero times. Run before schedule_tick so a freshly shown // tip's TTL arms the animation ticks that later clear it. let tip_shown = app.poll_clipboard_focus_tip(); schedule_tick(&mut animation_tick_at, &app, tick_interval); if result.needs_draw || tip_shown { if result.force_repaint { // Refocus heal wins over the resize debounce: a coalesced same-size // resize wouldn't autoresize-clear, so clear + full repaint now. resize_debounce_at = None; let _ = terminal.clear(); app.draw(terminal); last_draw_at = Instant::now(); draw_scheduled_at = None; } else if result.resize_only && !tip_shown { // Debounce: schedule a single draw after the size stabilizes. // Each new resize resets the timer so we only rebuild layout once. resize_debounce_at = Some(Instant::now() + RESIZE_DEBOUNCE); } else { // Non-resize change (or a shown tip): draw immediately // (picks up any pending resize too). resize_debounce_at = None; app.draw(terminal); last_draw_at = Instant::now(); draw_scheduled_at = None; } } // Sync appearance watcher when auto-mode toggles. sync_appearance_watcher(&mut appearance_watcher); } // Debounced resize: draw once the terminal size has stabilized. _ = resize_debounce => { resize_debounce_at = None; app.draw(terminal); last_draw_at = Instant::now(); draw_scheduled_at = None; schedule_tick(&mut animation_tick_at, &app, tick_interval); } // Deferred draw: fires when an ACP-triggered draw was throttled. _ = deferred_draw => { draw_scheduled_at = None; app.draw(terminal); last_draw_at = Instant::now(); } // Scroll clock: flush residual wheel/trackpad lines and detect // the 80ms stream gap on the 16ms redraw cadence, not the slower // animation fps. The next deadline is re-derived at loop top // from the post-tick scroll state. _ = scroll_tick => { if app.tick_scroll() { app.draw(terminal); last_draw_at = Instant::now(); draw_scheduled_at = None; } // Scroll dispatch can start work that animates (e.g. viewport // state), so keep the animation arm in sync too. schedule_tick(&mut animation_tick_at, &app, tick_interval); } _ = animation_tick => { animation_tick_at = None; // Lost-response recovery: finish any turn whose // `prompt_complete` broadcast outlived the grace window // without its `session/prompt` RPC response arriving // (see `dispatch::reconcile_overdue_turn_ends`). // `needs_animation()` keeps ticks alive while a reconcile // is armed, so this check cannot be starved. // Lost-response recovery: finish turns whose terminal armed // reconcile and grace expired without PromptResponse // (`dispatch::reconcile_overdue_turn_ends`). `needs_animation()` // keeps ticks alive while reconcile is armed. let reconciled = dispatch::reconcile_overdue_turn_ends(&mut app); if let Some(effs) = reconciled { if process_effects(effs, &mut tasks, &mut app, &progress_tx) { break; } app.draw(terminal); last_draw_at = Instant::now(); draw_scheduled_at = None; } else if app.tick() { app.draw(terminal); last_draw_at = Instant::now(); draw_scheduled_at = None; } // Keep ticking as long as there are running animations // or pending actions waiting to expire. schedule_tick(&mut animation_tick_at, &app, tick_interval); } _ = roster_poll => { roster_poll_at = None; // Only poll while the dashboard is open. When it is not active // we deliberately do NOT re-arm, so the loop isn't woken once // per second forever. In leader mode we poll the live FleetView // roster; outside leader mode we poll the local on-disk // idle-session list so the dashboard still shows idle sessions. let dashboard_open = matches!(app.active_view, ActiveView::AgentDashboard); if dashboard_open { let eff = if leader_status_rx.is_some() { Effect::FetchRoster } else { Effect::FetchDashboardSessions }; if process_effects(vec![eff], &mut tasks, &mut app, &progress_tx) { break; } roster_poll_at = Some(Instant::now() + ROSTER_POLL_INTERVAL); } } // Pre-generate the away recap so it's already on screen when the // user returns. Cheap no-op while focused / not-yet-eligible. _ = recap_poll => { if should_pregenerate_away_recap(&app) { let effs = dispatch::dispatch(Action::SendRecap { auto: true }, &mut app); if process_effects(effs, &mut tasks, &mut app, &progress_tx) { break; } } // Always re-arm: a cheap no-op fire while focused / not-yet-eligible. recap_poll_at = Some(Instant::now() + RECAP_POLL_INTERVAL); } // Hot-reload: config file changed (dev mode) or initial load. Ok(()) = config_watcher.changed() => { let mut config = config_watcher.current().clone(); // Preserve fields persisted via `~/.kigi/config.toml [ui]` // rather than `~/.kigi/pager.toml`. The watcher only knows // about pager.toml, so a hot-reload would otherwise revert // these to their hardcoded defaults. Compact carries the // PRE-reload render value; the canonical re-derive below owns // any correction, so its fast path cannot skip a needed // prompt-widget fan-out (`set_appearance` alone never syncs // `PromptWidget.compact`). config.prompt.compact = app.appearance.prompt.compact; config.show_timestamps = app.appearance.show_timestamps; config.show_timeline = app.appearance.show_timeline; tick_interval = config.animation.tick_interval(); crate::appearance::set_tab_width(config.scrollback.display.tab_width); app.set_appearance(config); app.apply_effective_compact(); // Reload the scroll settings from the pager caches (resynced // when a setting changes via the settings registry). app.scroll_config = crate::input::mouse::ScrollConfig::from_settings(); app.draw(terminal); last_draw_at = Instant::now(); draw_scheduled_at = None; } // System appearance changed (auto-theme mode). Ok(()) = async { if let Some(ref mut w) = appearance_watcher { w.changed().await } else { std::future::pending::>().await } } => { if let Some(ref w) = appearance_watcher && let Some(appearance) = w.current() { let config = theme_cache::auto_theme_config(); let new_kind = system_appearance::to_theme_kind( appearance, config.dark_theme, config.light_theme, ); let current = Theme::current_kind(); let effective = Theme::apply_kind(new_kind); if effective != current { tracing::info!( ?appearance, new_theme = %effective.display_name(), previous_theme = %current.display_name(), "system appearance changed, switching theme" ); app.draw(terminal); last_draw_at = Instant::now(); draw_scheduled_at = None; } } } // Leader connection status changes (reconnect lifecycle). Ok(()) = async { match leader_status_rx.as_mut() { Some(rx) => rx.changed().await.map_err(|_| ()), None => std::future::pending::>().await, } } => { use crate::acp::leader_bridge::ConnectionStatus; let Some(rx) = leader_status_rx.as_mut() else { // Guard: the async block above pends when None, but // defensive code should never .unwrap() in production. continue; }; let status = rx.borrow_and_update().clone(); match status { ConnectionStatus::Reconnecting { attempt } => { // Unified-log marker: an IPC reconnect mints a new leader-side // ClientId, which orphans responses to this client's in-flight // RPCs and drops outbound lines held across the swap — the // root trigger of the stuck-cancel bug. // Without this marker the reconnect is invisible in the // unified log (it only surfaced as ghost `session loaded` // replays with no matching `session.load.start`). crate::unified_log::warn( "leader.ipc.reconnecting", None, Some(serde_json::json!({ "attempt": attempt })), ); app.show_toast(&format!( "Disconnected. Reconnecting... (attempt {attempt})" )); app.draw(terminal); last_draw_at = Instant::now(); draw_scheduled_at = None; } ConnectionStatus::Connected { generation } if generation > last_leader_generation => { crate::unified_log::warn( "leader.ipc.reconnected", None, Some(serde_json::json!({ "generation": generation, "open_sessions": app .agents .values() .filter_map(|a| { a.session.session_id.as_ref().map(|s| s.0.to_string()) }) .collect::>(), })), ); last_leader_generation = generation; app.reconnect_pending = true; // Cancel any in-flight re-init from a previous reconnect // cycle and restore those agents' stashed transcripts — // their load requests rode the now-dead connection. if let Some(handle) = reconnect_abort_handle.take() { handle.abort(); } if let Some(prev) = reconnect_reinit.take() { for prev_id in prev.agent_ids { if let Some(agent) = app.agents.get_mut(&prev_id) { agent.finish_session_reload(prev.generation, false); } } } // Open a reload window on EVERY agent with a session // (active tab first so the visible one restores // fastest): a freshly (re-)elected leader has no // sessions in memory, so reloading only the active // session would leave every other tab on a session id // the new leader has never seen ("unknown session id" // on its next prompt). Replay is staged into fresh // state per agent and each existing transcript stays // recoverable until its load outcome is known. let fallback_cwd = app.cwd.clone(); let active_agent_id = match app.active_view { ActiveView::Agent(id) => Some(id), _ => None, }; let mut agent_ids: Vec = app.agents.keys().copied().collect(); agent_ids.sort_by_key(|id| Some(*id) != active_agent_id); let mut reload_agent_ids = Vec::new(); let mut load_plans = Vec::new(); for id in agent_ids { let Some(agent) = app.agents.get_mut(&id) else { continue; }; let Some(plan) = plan_reconnect_load(agent, &fallback_cwd) else { continue; }; // Keep the per-session display flag in lockstep with the // enforcement value (`autoMode`) we just re-seeded on this // agent (yolo wins, computed inside `plan_reconnect_load`). agent.session.auto_mode = plan.meta["autoMode"].as_bool().unwrap_or(false); agent.begin_session_reload(generation); // The reload adoption supersedes a pre-disconnect stash. app.pending_running_adoptions.remove(&id); reload_agent_ids.push(id); load_plans.push((id, plan)); } let any_reload = !reload_agent_ids.is_empty(); // Per-agent `auto_mode` was just re-seeded from the reload // meta; keep `/auto` feature-gate slash visibility in sync. app.sync_permission_mode_slash_gate(); let (done_tx, done_rx) = tokio::sync::oneshot::channel(); reconnect_reinit = Some(ReconnectReinit { rx: done_rx, agent_ids: reload_agent_ids, generation, }); let acp_tx = app.acp_tx.clone(); let join_handle = tokio::spawn(async move { // 30 s for initialize/authenticate plus a budget per // session/load (each load replays history and may // respawn MCP servers on the new leader). let timeout = Duration::from_secs( (30 + 30 * load_plans.len() as u64).min(300), ); // Inner result: `None` = init/auth failure (no // load was attempted); `Some(loads)` = per-agent // load outcomes with the optional mid-turn running // prompt id from each reload response. let ok = tokio::time::timeout(timeout, async { let init_req = acp::InitializeRequest::new(acp::ProtocolVersion::V1).client_capabilities(acp::ClientCapabilities::new().fs(acp::FileSystemCapabilities::new()).terminal(false)).meta(serde_json::json!({ "clientType": PAGER_CLIENT_TYPE, "clientVersion": PAGER_CLIENT_VERSION, }).as_object().cloned()); if let Err(e) = acp_send(init_req, &acp_tx).await { tracing::error!(error = %e, "reconnect: re-initialize failed"); return None; } let auth_req = acp::AuthenticateRequest::new(acp::AuthMethodId::new(crate::obf::auth::CACHED_TOKEN!())); if let Err(e) = acp_send(auth_req, &acp_tx).await { tracing::warn!(error = %e, "reconnect: re-authenticate failed"); } let mut loads = Vec::with_capacity(load_plans.len()); for (agent_id, plan) in load_plans { // Reconnect path — no resolved compat in scope; default // (all-on) preserves existing behavior. let mcp_servers = kigi_shell::util::config::load_mcp_servers( &plan.cwd, &kigi_tools::types::compat::CompatConfig::default(), ); let load_req = acp::LoadSessionRequest::new(plan.session_id, plan.cwd).mcp_servers(mcp_servers).meta(plan.meta.as_object().cloned()); match acp_send(load_req, &acp_tx).await { Ok(resp) => { loads.push(AgentLoadOutcome { agent_id, success: true, running_prompt_id: effects::parse_session_load_running_prompt_id( resp.meta.as_ref(), ), }); } Err(e) => { tracing::error!(error = %e, "reconnect: reload session failed"); // Keep restoring the remaining sessions — // one broken session must not doom the rest. loads.push(AgentLoadOutcome { agent_id, success: false, running_prompt_id: None, }); } } } Some(loads) }) .await; let outcome = match ok { Ok(Some(loads)) => ReinitOutcome { init_ok: true, loads, }, Ok(None) => ReinitOutcome { init_ok: false, loads: Vec::new(), }, Err(_) => { tracing::error!("reconnect re-initialization timed out"); ReinitOutcome { init_ok: false, loads: Vec::new(), } } }; let _ = done_tx.send(outcome); }); reconnect_abort_handle = Some(join_handle.abort_handle()); app.show_toast(if any_reload { "Reconnected. Reloading session..." } else { "Reconnected. Re-initializing..." }); app.draw(terminal); last_draw_at = Instant::now(); draw_scheduled_at = None; } ConnectionStatus::Failed { ref error } => { app.show_toast(&format!("Connection failed: {error}")); app.draw(terminal); last_draw_at = Instant::now(); draw_scheduled_at = None; } _ => {} } } // Reconnect re-initialization completed (or failed). result = async { match reconnect_reinit.as_mut() { Some(pending) => (&mut pending.rx).await, None => std::future::pending::>().await, } } => { let Some(pending) = reconnect_reinit.take() else { continue; }; reconnect_abort_handle = None; app.reconnect_pending = false; let outcome = match result { Ok(outcome) => outcome, Err(_) => { tracing::error!("reconnect re-init task failed (sender dropped)"); ReinitOutcome { init_ok: false, loads: Vec::new() } } }; // Finalize the reload windows on the agents the re-init was // started for — NOT whatever view is active now (see // `SessionReload` for the outcome handling). Each window // resolves on ITS load outcome (one broken session must not // discard the other tabs' replayed transcripts), then a // mid-reconnect running turn is adopted, mirroring the // `SessionLoaded` adoption in dispatch.rs. let mut loads: std::collections::HashMap<_, _> = outcome .loads .into_iter() .map(|l| (l.agent_id, (l.success, l.running_prompt_id))) .collect(); // Resolved BEFORE the finalize loop drains `loads` via `remove` // (see `reconnect_restore_outcome`). let active_agent_id = match app.active_view { ActiveView::Agent(id) => Some(id), _ => None, }; let (restored, active_restored) = reconnect_restore_outcome( outcome.init_ok, &pending.agent_ids, &loads, active_agent_id, ); for id in &pending.agent_ids { let (ok, running_prompt_id) = loads.remove(id).unwrap_or((false, None)); if let Some(agent) = app.agents.get_mut(id) { agent.finalize_reload_and_maybe_adopt( pending.generation, ok, running_prompt_id, ); } } if pending.agent_ids.is_empty() { // Nothing was reloaded (no open sessions at reconnect). app.show_toast("Reconnected."); } else if restored { app.show_toast("Session restored. In-progress tools and terminals were lost."); } else { app.show_toast("Session restore failed. Kept the existing transcript."); } // Re-trigger the queue drain suppressed during the outage: every // normal trigger (PromptResponse, DrainQueue, send-prompt, // session-created) early-returns while `reconnect_pending` is set // and defers here, and the agent was just force-idled above. Gate // on the active tab's own restore (see `reconnect_restore_outcome`): // a failed active restore suppresses the drain, since sending into // an unrestored session would be wrong. if active_restored { let drain_effects = dispatch::dispatch(Action::DrainQueue, &mut app); if process_effects(drain_effects, &mut tasks, &mut app, &progress_tx) { return Ok(make_run_result(&app)); } } app.draw(terminal); last_draw_at = Instant::now(); draw_scheduled_at = None; } } } app.notification_service.shutdown(); Ok(make_run_result(&app)) } /// Schedule the next animation tick if there are running entries and none is pending. /// /// Load `UiConfig` from the shell's layered config at startup. /// Falls back to `UiConfig::default()` on any failure. pub(crate) fn load_initial_ui_config() -> kigi_shell::agent::config::UiConfig { use kigi_shell::agent::config::UiConfig; let Ok(root) = kigi_shell::config::load_effective_config() else { return UiConfig::default(); }; let Some(ui_value) = root.get("ui").cloned() else { return UiConfig::default(); }; ui_value.try_into::().unwrap_or_default() } /// Config `Option` mirrors seeded once at startup. `None` = no /// TOML override; the modal falls back to the per-setting default. #[derive(Default)] struct InitialConfigSessionBools { show_tips: Option, auto_update: Option, ask_user_question_timeout_enabled: Option, } fn load_initial_config_session_bools() -> InitialConfigSessionBools { let Ok(root) = kigi_shell::config::load_effective_config() else { return InitialConfigSessionBools::default(); }; let cli_bool = |key: &str| -> Option { root.get("cli")?.get(key)?.as_bool() }; InitialConfigSessionBools { show_tips: cli_bool("show_tips"), auto_update: cli_bool("auto_update"), ask_user_question_timeout_enabled: root .get("toolset") .and_then(|t| t.get("ask_user_question")) .and_then(|a| a.get("timeout_enabled")) .and_then(|v| v.as_bool()), } } /// Whether to pre-generate the automatic "return-from-away" recap right now. /// /// True only when the terminal has been unfocused past the recap threshold /// (once per away period, gated by [`FocusTracker::recap_due`]), the shell has /// rolled out session recap (`session_recap_available`), the user has not opted /// out via `ui.notifications.session_recap`, and the active agent has *finished /// its turn* with nothing pending that could wake it — i.e. idle, no modal, no /// pending question, an established session, and no running background task (a /// bg task completing can auto-wake the agent). Generating it now means the /// recap is already in the scrollback when the user returns. /// Sync shell `sessionRecap` into execution gate + every existing slash surface. /// Dashboard created later is seeded in `dispatch_open_dashboard`. fn apply_session_recap_available(app: &mut AppView, available: bool) { app.session_recap_available = available; for agent in app.agents.values_mut() { agent.set_session_recap_available(available); } app.welcome_prompt.set_recap_visible(available); if let Some(dashboard) = app.dashboard.as_mut() { dashboard.set_recap_visible(available); } } fn should_pregenerate_away_recap(app: &AppView) -> bool { if !(app.session_recap_available && app.notification_service.focus_tracker.recap_due() && app.notification_service.config().session_recap) { return false; } let ActiveView::Agent(id) = app.active_view else { return false; }; app.agents.get(&id).is_some_and(|agent| { agent.session.state.is_idle() && agent.active_modal.is_none() && agent.question_view.is_none() && agent.session.session_id.is_some() && !agent.session.has_running_bg_tasks() }) } fn schedule_tick(tick_at: &mut Option, app: &AppView, interval: Duration) { if tick_at.is_none() { let interval = match app.tick_demand() { crate::app::app_view::TickDemand::None => return, // A view can request a faster cadence than the configured // animation fps (e.g. the /gboom easter egg targets ~30 fps). crate::app::app_view::TickDemand::Fast => match app.tick_interval_ceiling() { Some(ceiling) => interval.min(ceiling), None => interval, }, // Only low-frequency work (welcome shimmer, Cmd link poll): // don't spin the full 30fps loop for it. crate::app::app_view::TickDemand::Slow => { interval.max(crate::app::app_view::SLOW_TICK_INTERVAL) } }; *tick_at = Some(Instant::now() + interval); } } /// Sync `appearance_watcher` with the current `AUTO_MODE` flag. /// Starts or stops the watcher as needed; no-op when consistent. fn sync_appearance_watcher(watcher: &mut Option) { let should_auto = theme_cache::is_auto_mode(); if should_auto != watcher.is_some() { *watcher = SystemAppearanceWatcher::start_if_auto(should_auto); } } /// Build [`ExitInfo`] from the active agent's session (if any). /// /// `exit_info` is only consumed on the plain-quit path; a pending `relaunch` /// short-circuits before it is read and carries its own session id. fn make_run_result(app: &AppView) -> RunResult { RunResult { exit_info: app.active_session_id().map(|sid| super::ExitInfo { session_id: sid.to_string(), minimal: app.screen_mode.is_minimal(), }), quit_for_update: app.quit_for_update, relaunch: app.relaunch.clone(), } } /// Result of draining and processing terminal events. struct DrainResult { /// Whether any event produced a visual change requiring a draw. needs_draw: bool, /// Whether the app should quit. should_quit: bool, /// Whether resize was the only source of change (no key/mouse/action changes). /// When true, the caller should debounce the draw to avoid redundant layout /// rebuilds during continuous terminal resize drags. resize_only: bool, /// Whether the next draw must be preceded by a full clear+repaint, set on /// refocus in editor/multiplexer contexts to heal out-of-band stranded rows. force_repaint: bool, } struct RoutedInputEvent { event: Event, paste_provenance: PasteProvenance, } fn normalize_input_event(event: Event) -> RoutedInputEvent { #[cfg(target_os = "linux")] { use crossterm::event::{MouseButton, MouseEventKind}; let is_unmodified_middle_down = match &event { Event::Mouse(mouse) => { mouse.kind == MouseEventKind::Down(MouseButton::Middle) && mouse.modifiers.is_empty() } _ => false, }; if is_unmodified_middle_down && let Some(text) = crate::clipboard::system_primary_selection_get() { return RoutedInputEvent { event: Event::Paste(text), paste_provenance: PasteProvenance::X11Primary, }; } } RoutedInputEvent { event, paste_provenance: PasteProvenance::Terminal, } } /// Process a terminal event, then drain any buffered events before returning. /// /// Crossterm buffers input events while the app is drawing. Without draining, /// each event triggers a separate `draw()` call. When draw takes longer than /// the scroll cadence (16 ms), hundreds of buffered scroll events cause hundreds /// of sequential draws, freezing the UI for seconds or minutes. /// /// Runs [`coalesce_rapid_keys`] and the persistent /// [`CsiFragmentFilter`](super::csi_filter::CsiFragmentFilter) before /// processing to fix paste on terminals without bracketed paste (e.g. /// Windows PowerShell) and filter leaked CSI fragments (SGR mouse and focus reports). async fn drain_and_process( first: Event, input_rx: &mut tokio::sync::mpsc::UnboundedReceiver, app: &mut AppView, tasks: &mut JoinSet, progress_tx: &tokio::sync::mpsc::UnboundedSender, csi_filter: &mut super::csi_filter::CsiFragmentFilter, xt_filter: &mut super::xt_filter::XtversionFilter, ) -> DrainResult { let mut needs_draw = false; let mut had_resize = false; let mut had_non_resize_change = false; let mut force_repaint = false; // Collect all immediately-available events for paste coalescing. let mut raw_events = vec![first]; drain_immediate(&mut raw_events, input_rx); // XTVERSION reply removal must precede paste coalescing so reply chars // are never folded into a synthetic Paste. if xt_filter.armed() { raw_events = super::xt_filter::filter_with_fragment_wait(xt_filter, raw_events, input_rx).await; } // On terminals without bracketed paste, try to capture more events // that may still be in transit from the input reader thread. if should_extend_for_paste(&raw_events) && detect_paste(&mut raw_events, input_rx).await { collect_remaining_paste(&mut raw_events, input_rx).await; // The paste extension pulled more events off the channel without // running them through the still-armed filter — a late or split // XTVERSION reply could otherwise be folded into the paste. if xt_filter.armed() { raw_events = super::xt_filter::filter_with_fragment_wait(xt_filter, raw_events, input_rx).await; } } // The /gboom game tracks keys by press → release, so it needs the // release events that `coalesce_rapid_keys` strips (and it never // pastes). Skip coalescing while it owns input. let coalesced = if app.gboom_active() { raw_events } else { coalesce_rapid_keys(raw_events) }; let coalesced = csi_filter.filter(coalesced); let coalesced = coalesced .into_iter() .map(normalize_input_event) .collect::>(); let mut handle_one = |routed: &RoutedInputEvent| -> bool { let ev = &routed.event; match ev { Event::FocusGained => { // Force a full repaint on refocus to heal out-of-band stranded rows. // Sets needs_draw (not had_non_resize_change); the draw site honors force_repaint // ahead of the resize debounce, clearing even a coalesced same-size resize. if crate::terminal::terminal_context().repaints_pane_out_of_band() { force_repaint = true; needs_draw = true; } // Capture recap eligibility BEFORE on_focus_gained() clears the // away timer. Auto recap requires the shell rollout flag plus // the notifications opt-in; manual `/recap` only needs the flag. let recap_due = app.session_recap_available && app.notification_service.focus_tracker.recap_due() && app.notification_service.config().session_recap; app.notification_service.focus_tracker.on_focus_gained(); // Pre-warm AppKit's lazy dlopen off the UI thread (once) so the // first changeCount poll after returning is just the cheap // metadata read and never stalls a frame on the framework load. // FocusGained is itself an active loop iteration, so the // opportunistic poll (driven after drain_and_process) does the // actual clipboard check — no debounce, no timer, and // `needs_animation` is never kept hot for it. if app.contextual_hints.image_input && crate::clipboard::clipboard_image_probe_supported() { crate::clipboard::prewarm_image_probe(); } // Restore Prompt on refocus: needs-input overlay always, else idle non-vim. match app.active_view { ActiveView::Agent(id) => { if let Some(agent) = app.agents.get_mut(&id) && agent.should_restore_prompt_on_focus_gained() { agent.set_active_pane(crate::views::agent::ActivePane::Prompt, false); needs_draw = true; had_non_resize_change = true; } // Automatic "where was I" recap: the user just returned // after being away long enough. Only when the session is // idle and not blocked by a modal or pending question. // Compute eligibility into a bool first so the immutable // agent borrow is dropped before dispatch (&mut app). let eligible = app.agents.get(&id).is_some_and(|agent| { agent.session.state.is_idle() && agent.active_modal.is_none() && agent.question_view.is_none() && agent.session.session_id.is_some() && !agent.session.has_running_bg_tasks() }); if recap_due && eligible { let effs = dispatch::dispatch( crate::app::actions::Action::SendRecap { auto: true }, app, ); if process_effects(effs, tasks, app, progress_tx) { return true; } needs_draw = true; had_non_resize_change = true; } } ActiveView::Welcome => { if matches!(app.auth_state, AuthState::Done) && !app.welcome_prompt_focused { app.welcome_prompt_focused = true; needs_draw = true; had_non_resize_change = true; } } // The dashboard manages its own input/overview focus // (`list_focused`); refocusing the terminal must not // override the user's choice (e.g. vim overview focus). ActiveView::AgentDashboard => {} } return false; } Event::FocusLost => { app.notification_service.focus_tracker.on_focus_lost(); // The /gboom game latches held keys until their release; a // release can be lost while unfocused, so stop all movement. if app.gboom_active() { app.gboom_release_all_games(); needs_draw = true; } return false; } _ => {} } let is_resize = matches!(ev, Event::Resize(_, _)); match app.handle_input_with_paste_provenance(ev, routed.paste_provenance) { InputOutcome::Action(action) => { let effs = dispatch::dispatch(action, app); if process_effects(effs, tasks, app, progress_tx) { return true; } needs_draw = true; had_non_resize_change = true; } InputOutcome::ActionThenForward(action) => { // Dispatch the action (e.g. create session), then re-process // the same event through the now-active view so the input // (character, paste) lands in the session's prompt. let effs = dispatch::dispatch(action, app); if process_effects(effs, tasks, app, progress_tx) { return true; } if let InputOutcome::Action(follow_up) = app.handle_input_with_paste_provenance(ev, routed.paste_provenance) { let effs = dispatch::dispatch(follow_up, app); if process_effects(effs, tasks, app, progress_tx) { return true; } } needs_draw = true; had_non_resize_change = true; } InputOutcome::ActionPair(first, second) => { // Dispatch both in order; first must fully resolve // before second (e.g. revert preview then open reset). let effs = dispatch::dispatch(first, app); if process_effects(effs, tasks, app, progress_tx) { return true; } let effs = dispatch::dispatch(second, app); if process_effects(effs, tasks, app, progress_tx) { return true; } needs_draw = true; had_non_resize_change = true; } InputOutcome::Changed => { needs_draw = true; if is_resize { had_resize = true; } else { had_non_resize_change = true; } } // AppView converts ArmPending → Changed; defensive if one slips through. InputOutcome::ArmPending { .. } => { needs_draw = true; had_non_resize_change = true; } InputOutcome::Unchanged => {} } false }; for routed in &coalesced { if handle_one(routed) { return DrainResult { needs_draw, should_quit: true, resize_only: false, force_repaint: false, }; } } DrainResult { needs_draw, should_quit: false, resize_only: had_resize && !had_non_resize_change, force_repaint, } } // ── Paste coalescing for terminals without bracketed paste ─────────── /// Timeout for the first extension round (detection). If no event /// arrives within this window the batch was a normal keystroke. const PASTE_DETECT_TIMEOUT: Duration = Duration::from_millis(2); /// Timeout for subsequent rounds once paste has been detected. const PASTE_CONTINUE_TIMEOUT: Duration = Duration::from_millis(10); /// Safety cap on events accumulated in one extension pass. const PASTE_EXTEND_MAX_EVENTS: usize = 5_000; /// Returns `true` when the batch contains pasteable key events but no /// `Event::Paste` (i.e. bracketed paste is not handling it). fn should_extend_for_paste(events: &[Event]) -> bool { !events.iter().any(|e| matches!(e, Event::Paste(_))) && events.iter().any(is_pasteable_key_event) } /// Wait [`PASTE_DETECT_TIMEOUT`] for a follow-up event. Returns `true` /// if a **pasteable key event** arrives within the window. Non-key events /// (mouse, focus, releases) are collected but do not count as paste evidence. async fn detect_paste( batch: &mut Vec, input_rx: &mut tokio::sync::mpsc::UnboundedReceiver, ) -> bool { match tokio::time::timeout(PASTE_DETECT_TIMEOUT, input_rx.recv()).await { Ok(Some(ev)) => { let prev_len = batch.len(); batch.push(ev); drain_immediate(batch, input_rx); batch[prev_len..].iter().any(is_pasteable_key_event) } _ => false, } } /// Collect remaining paste events using [`PASTE_CONTINUE_TIMEOUT`]. /// Only pasteable key events extend the timeout; non-key events are /// collected but do not keep the loop alive. async fn collect_remaining_paste( batch: &mut Vec, input_rx: &mut tokio::sync::mpsc::UnboundedReceiver, ) { let mut extended = 0usize; loop { if extended >= PASTE_EXTEND_MAX_EVENTS { break; } match tokio::time::timeout(PASTE_CONTINUE_TIMEOUT, input_rx.recv()).await { Ok(Some(ev)) => { let prev_len = batch.len(); batch.push(ev); extended += 1; drain_immediate(batch, input_rx); if !batch[prev_len..].iter().any(is_pasteable_key_event) { continue; } } _ => break, } } } /// Non-blocking drain of all immediately available events. pub(super) fn drain_immediate( batch: &mut Vec, input_rx: &mut tokio::sync::mpsc::UnboundedReceiver, ) { while let Ok(ev) = input_rx.try_recv() { batch.push(ev); } } /// Minimum key events in a run to trigger paste coalescing. const PASTE_COALESCE_THRESHOLD: usize = 3; /// Minimum run length for the Windows path-shape coalesce branch. /// Covers the shortest realistic dropped image path (`C:\x.png`, /// `/a.png`) while leaving short typed prose alone. #[cfg(target_os = "windows")] const PATH_COALESCE_THRESHOLD: usize = 8; /// Check if a terminal event is a pasteable key press — a character, /// Enter, or Tab with no control modifiers (Ctrl/Alt/Super). /// /// Only matches `Press` (not `Repeat` or `Release`). Repeat events come /// from held keys, not paste; Release events carry no semantic content. fn is_pasteable_key_event(ev: &Event) -> bool { match ev { Event::Key(ke) if ke.kind == KeyEventKind::Press => match ke.code { KeyCode::Char(_) => { ke.modifiers.is_empty() || ke.modifiers == KeyModifiers::SHIFT || crate::input::key::is_altgr(ke.modifiers) } KeyCode::Enter | KeyCode::Tab => ke.modifiers.is_empty(), _ => false, }, _ => false, } } /// Coalesce runs of rapid key events into synthetic `Event::Paste` /// events. On terminals without bracketed paste, pasted text arrives /// as individual key events; Enter keys mid-run would otherwise /// trigger "submit prompt" and split multi-line pastes. /// /// A contiguous run of character/Enter/Tab events is replaced with a /// single `Event::Paste` when EITHER: /// /// 1. `>= PASTE_COALESCE_THRESHOLD` events AND at least one Enter is /// followed by more characters (distinguishes `type + submit` from /// `pasted multiline`). /// 2. **Windows only:** `>= PATH_COALESCE_THRESHOLD` events AND the /// assembled text starts with a drag-drop-style path anchor. Some /// Windows Terminal versions deliver dropped paths as keystrokes /// instead of a bracketed paste; this branch recovers them. /// /// No-op when bracketed paste already arrives as `Event::Paste`. fn coalesce_rapid_keys(events: Vec) -> Vec { // Fast path: not enough events for coalescing to trigger. if events.len() < PASTE_COALESCE_THRESHOLD { return events; } // If Event::Paste fragments are mixed with key events (Windows // Terminal can split a large bracketed paste across read boundaries), // merge everything into a single Event::Paste. let (mut has_paste, mut has_keys) = (false, false); for e in events.iter() { has_paste |= matches!(e, Event::Paste(_)); has_keys |= is_pasteable_key_event(e); } if has_paste { return if has_keys { merge_paste_fragments(events) } else { events }; } // Remove Release events — handlers ignore them and they'd break run // detection. let events: Vec = events .into_iter() .filter(|ev| { !matches!(ev, Event::Key(ke) if ke.kind == KeyEventKind::Release) }) .collect(); let mut result = Vec::with_capacity(events.len()); let mut i = 0; while i < events.len() { if is_pasteable_key_event(&events[i]) { let run_start = i; let mut text = String::new(); let mut seen_enter = false; let mut has_char_after_enter = false; while i < events.len() && is_pasteable_key_event(&events[i]) { if let Event::Key(ke) = &events[i] { match ke.code { KeyCode::Char(c) => { text.push(c); if seen_enter { has_char_after_enter = true; } } KeyCode::Enter => { text.push('\n'); seen_enter = true; } KeyCode::Tab => { text.push('\t'); if seen_enter { has_char_after_enter = true; } } _ => unreachable!("is_pasteable_key_event guards this"), } } i += 1; } let run_len = i - run_start; let multiline_paste = run_len >= PASTE_COALESCE_THRESHOLD && has_char_after_enter; // Windows fallback for drag-drops that arrive as a key // burst instead of a bracketed paste — reuse the drop // classifier's anchor detector so the two layers can't // drift on what counts as a path. #[cfg(target_os = "windows")] let path_shaped_drop = run_len >= PATH_COALESCE_THRESHOLD && crate::prompt_images::starts_with_drop_anchor(&text); #[cfg(not(target_os = "windows"))] let path_shaped_drop = false; if multiline_paste || path_shaped_drop { tracing::debug!( run_len, text_len = text.len(), path_shape = path_shaped_drop, "coalesced rapid key events into paste" ); result.push(Event::Paste(text)); } else { for ev in &events[run_start..i] { result.push(ev.clone()); } } } else { result.push(events[i].clone()); i += 1; } } result } pub(super) fn is_bare_esc_press(ev: &Event) -> bool { matches!( ev, Event::Key(ke) if ke.code == KeyCode::Esc && ke.kind == KeyEventKind::Press && ke.modifiers == KeyModifiers::NONE ) } /// Merge `Event::Paste` fragments and interleaved key events into a /// single `Event::Paste`. Non-paste, non-key events (Resize, Mouse, /// Focus) are preserved in order around the merged paste. fn merge_paste_fragments(events: Vec) -> Vec { let mut result = Vec::new(); let mut merged_text = String::new(); for ev in events { match &ev { Event::Paste(text) => merged_text.push_str(text), Event::Key(ke) if is_pasteable_key_event(&ev) => match ke.code { KeyCode::Char(c) => merged_text.push(c), KeyCode::Enter => merged_text.push('\n'), KeyCode::Tab => merged_text.push('\t'), _ => {} }, // Non-pasteable keys (Ctrl+C, Backspace, arrows, Release // events, etc.) are artifacts of paste fragmentation — drop. Event::Key(_) => {} _ => { if !merged_text.is_empty() { result.push(Event::Paste(std::mem::take(&mut merged_text))); } result.push(ev); } } } if !merged_text.is_empty() { result.push(Event::Paste(merged_text)); } result } /// Spawn effects into the task set. Returns `true` if the app should quit. fn process_effects( effs: Vec, tasks: &mut JoinSet, app: &mut AppView, progress_tx: &tokio::sync::mpsc::UnboundedSender, ) -> bool { let flags = effects::SessionFlags { plan_mode: app.plan_mode, subagents: app.subagents, ask_user: app.ask_user, restore_code: app.restore_code, agent_override: app.agent_override.clone(), yolo_mode: app.default_yolo, auto_mode: super::dispatch::effective_auto( app.default_yolo, matches!(app.current_ui.permission_mode.as_deref(), Some("auto")), ), chat_mode: app.chat_mode, screen_mode_label: Some(app.screen_mode.meta_label()), is_api_key_auth: app.is_api_key_auth, }; for eff in effs { let (quit, meta) = effects::execute(eff, tasks, &app.acp_tx, &app.cwd, &flags, progress_tx); // Install auth abort handle if the current auth state still matches. if let Some((seq, abort_handle)) = meta.auth_abort_handle && let super::app_view::AuthState::Authenticating { request_seq, handle, .. } = &mut app.auth_state && *request_seq == seq { *handle = Some(abort_handle); } if quit { return true; } } false } #[cfg(test)] mod tests { use super::*; use crossterm::event::{KeyEvent, KeyEventState}; // ── plan_reconnect_load ────────────────────────────────────────────── #[test] fn plan_reconnect_load_requires_session_id() { let agent = crate::test_util::make_agent_view(None, "/work/project"); assert!(plan_reconnect_load(&agent, std::path::Path::new("/pager/cwd")).is_none()); } /// The session's own cwd keys its on-disk storage — the pager cwd /// is only a fallback for agents without one. #[test] fn plan_reconnect_load_prefers_session_cwd_over_fallback() { let agent = crate::test_util::make_agent_view(Some("sess-1"), "/work/worktree-a"); let plan = plan_reconnect_load(&agent, std::path::Path::new("/pager/cwd")).unwrap(); assert_eq!(plan.session_id.0.as_ref(), "sess-1"); assert_eq!(plan.cwd, std::path::PathBuf::from("/work/worktree-a")); let agent = crate::test_util::make_agent_view(Some("sess-1"), ""); let plan = plan_reconnect_load(&agent, std::path::Path::new("/pager/cwd")).unwrap(); assert_eq!(plan.cwd, std::path::PathBuf::from("/pager/cwd")); } /// The reconnect cursor rides `_meta.cursor` when known; yolo mode /// always rides `_meta.yoloMode`. Auto rides `_meta.autoMode` per-agent. #[test] fn plan_reconnect_load_meta_carries_cursor_and_yolo() { let mut agent = crate::test_util::make_agent_view(Some("sess-1"), "/work"); let plan = plan_reconnect_load(&agent, std::path::Path::new("/pager/cwd")).unwrap(); assert_eq!(plan.meta["yoloMode"], serde_json::json!(false)); assert!( plan.meta.get("cursor").is_none(), "no cursor key before any event was applied" ); // autoMode is always set explicitly (false when not in auto) so the leader's // capability injection can't re-enable Auto on reconnect. assert_eq!(plan.meta["autoMode"], serde_json::json!(false)); agent.last_seen_event_id = Some("sess-1-42".into()); agent.session.yolo_mode = true; let plan = plan_reconnect_load(&agent, std::path::Path::new("/pager/cwd")).unwrap(); assert_eq!(plan.meta["yoloMode"], serde_json::json!(true)); assert_eq!(plan.meta["cursor"], serde_json::json!("sess-1-42")); } #[test] fn plan_reconnect_load_meta_carries_auto_mode_from_session() { // Auto rides `_meta.autoMode`, derived from THIS agent's own // `auto_mode` (per-agent, symmetric with yolo) — not the global UI mirror. let mut agent = crate::test_util::make_agent_view(Some("sess-1"), "/work"); agent.session.auto_mode = true; let plan = plan_reconnect_load(&agent, std::path::Path::new("/pager/cwd")).unwrap(); assert_eq!(plan.meta["yoloMode"], serde_json::json!(false)); assert_eq!(plan.meta["autoMode"], serde_json::json!(true)); // Yolo wins: autoMode is explicitly false even if the session is in auto. let mut agent = crate::test_util::make_agent_view(Some("sess-1"), "/work"); agent.session.auto_mode = true; agent.session.yolo_mode = true; let plan = plan_reconnect_load(&agent, std::path::Path::new("/pager/cwd")).unwrap(); assert_eq!(plan.meta["yoloMode"], serde_json::json!(true)); assert_eq!(plan.meta["autoMode"], serde_json::json!(false)); } /// Multi-agent reconnect must seed each tab's `autoMode` from ITS OWN /// session, not a shared global mirror: an active Auto tab and a background /// Ask tab reconnect with `autoMode:true` and `autoMode:false` respectively. #[test] fn plan_reconnect_load_multi_agent_uses_per_agent_auto() { let mut active = crate::test_util::make_agent_view(Some("sess-active"), "/work"); active.session.auto_mode = true; let background = crate::test_util::make_agent_view(Some("sess-bg"), "/work"); // background.session.auto_mode stays false (Ask). let active_plan = plan_reconnect_load(&active, std::path::Path::new("/pager/cwd")).unwrap(); let background_plan = plan_reconnect_load(&background, std::path::Path::new("/pager/cwd")).unwrap(); assert_eq!(active_plan.meta["autoMode"], serde_json::json!(true)); assert_eq!( background_plan.meta["autoMode"], serde_json::json!(false), "background Ask tab must reconnect with autoMode:false regardless of the active tab" ); } // ── reconnect_restore_outcome ──────────────────────────────────────── /// The regression guard: one background tab fails, the active tab /// succeeds. The whole-reconnect flag goes false (toast says "failed"), /// but the active tab's OWN drain must still fire — a failed background tab /// must not strand prompts queued on the healthy active tab. #[test] fn reconnect_drain_gates_on_active_agent_not_all_agents() { use super::super::agent::AgentId; let active = AgentId(0); let background = AgentId(1); let mut loads = std::collections::HashMap::new(); loads.insert(active, (true, None)); loads.insert(background, (false, None)); let pending = vec![active, background]; let (all_restored, active_restored) = reconnect_restore_outcome(true, &pending, &loads, Some(active)); assert!( !all_restored, "a failed background tab keeps the whole-reconnect flag false (toast)" ); assert!( active_restored, "the active tab's own success still drains its queue" ); } /// The active tab's OWN reload failed: its drain stays suppressed even /// though a background tab succeeded. #[test] fn reconnect_drain_blocked_when_active_agent_failed() { use super::super::agent::AgentId; let active = AgentId(0); let background = AgentId(1); let mut loads = std::collections::HashMap::new(); loads.insert(active, (false, None)); loads.insert(background, (true, None)); let pending = vec![active, background]; let (all_restored, active_restored) = reconnect_restore_outcome(true, &pending, &loads, Some(active)); assert!(!all_restored); assert!( !active_restored, "the active tab's own failure must block its drain" ); } /// Single-agent behavior is preserved: the lone active tab succeeds → both /// flags true (toast "restored" + drain). #[test] fn reconnect_drain_single_agent_success_preserved() { use super::super::agent::AgentId; let active = AgentId(0); let mut loads = std::collections::HashMap::new(); loads.insert(active, (true, None)); let pending = vec![active]; let (all_restored, active_restored) = reconnect_restore_outcome(true, &pending, &loads, Some(active)); assert!(all_restored); assert!(active_restored); } /// A failed init (`init_ok == false`, empty `loads`) suppresses everything. #[test] fn reconnect_drain_blocked_when_init_failed() { use super::super::agent::AgentId; let active = AgentId(0); let loads = std::collections::HashMap::new(); let pending = vec![active]; let (all_restored, active_restored) = reconnect_restore_outcome(false, &pending, &loads, Some(active)); assert!(!all_restored); assert!(!active_restored); } /// No active agent (dashboard/welcome view): nothing to drain, even when /// every reloaded tab restored. #[test] fn reconnect_drain_blocked_when_no_active_agent() { use super::super::agent::AgentId; let background = AgentId(1); let mut loads = std::collections::HashMap::new(); loads.insert(background, (true, None)); let pending = vec![background]; let (all_restored, active_restored) = reconnect_restore_outcome(true, &pending, &loads, None); assert!(all_restored); assert!( !active_restored, "no active agent → no active-tab drain to fire" ); } fn press(code: KeyCode) -> Event { Event::Key(KeyEvent { code, modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, state: KeyEventState::NONE, }) } fn release(code: KeyCode) -> Event { Event::Key(KeyEvent { code, modifiers: KeyModifiers::NONE, kind: KeyEventKind::Release, state: KeyEventState::NONE, }) } fn press_shift(code: KeyCode) -> Event { Event::Key(KeyEvent { code, modifiers: KeyModifiers::SHIFT, kind: KeyEventKind::Press, state: KeyEventState::NONE, }) } fn press_ctrl(code: KeyCode) -> Event { Event::Key(KeyEvent { code, modifiers: KeyModifiers::CONTROL, kind: KeyEventKind::Press, state: KeyEventState::NONE, }) } #[cfg(target_os = "linux")] fn mouse_event(kind: crossterm::event::MouseEventKind, modifiers: KeyModifiers) -> Event { Event::Mouse(crossterm::event::MouseEvent { kind, column: 7, row: 11, modifiers, }) } #[cfg(target_os = "linux")] #[test] fn unmodified_middle_down_reads_primary_once() { use crossterm::event::{MouseButton, MouseEventKind}; crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook { text: Some("CLIPBOARD".to_owned()), primary_text: Some("PRIMARY\nexact".to_owned()), x11_primary_available: true, ..Default::default() }); let normalized = normalize_input_event(mouse_event( MouseEventKind::Down(MouseButton::Middle), KeyModifiers::NONE, )); assert_eq!(normalized.event, Event::Paste("PRIMARY\nexact".to_owned())); assert_eq!(normalized.paste_provenance, PasteProvenance::X11Primary); assert_eq!(crate::clipboard::primary_selection_read_call_count(), 1); crate::clipboard::clear_clipboard_probe_hook(); } #[cfg(target_os = "linux")] #[test] fn nonqualifying_mouse_events_do_not_read_primary() { use crossterm::event::{MouseButton, MouseEventKind}; crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook { primary_text: Some("PRIMARY".to_owned()), x11_primary_available: true, ..Default::default() }); let release = mouse_event(MouseEventKind::Up(MouseButton::Middle), KeyModifiers::NONE); let normalized = normalize_input_event(release.clone()); assert_eq!(normalized.event, release); assert_eq!(normalized.paste_provenance, PasteProvenance::Terminal); let modified = mouse_event( MouseEventKind::Down(MouseButton::Middle), KeyModifiers::SHIFT, ); let normalized = normalize_input_event(modified.clone()); assert_eq!(normalized.event, modified); assert_eq!(normalized.paste_provenance, PasteProvenance::Terminal); let left = mouse_event(MouseEventKind::Down(MouseButton::Left), KeyModifiers::NONE); let normalized = normalize_input_event(left.clone()); assert_eq!(normalized.event, left); assert_eq!(normalized.paste_provenance, PasteProvenance::Terminal); assert_eq!(crate::clipboard::primary_selection_read_call_count(), 0); crate::clipboard::clear_clipboard_probe_hook(); } #[cfg(target_os = "linux")] #[test] fn empty_primary_preserves_original_middle_event() { use crossterm::event::{MouseButton, MouseEventKind}; crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook { primary_text: Some(String::new()), x11_primary_available: true, ..Default::default() }); let middle = mouse_event( MouseEventKind::Down(MouseButton::Middle), KeyModifiers::NONE, ); let normalized = normalize_input_event(middle.clone()); assert_eq!(normalized.event, middle); assert_eq!(normalized.paste_provenance, PasteProvenance::Terminal); assert_eq!(crate::clipboard::primary_selection_read_call_count(), 1); crate::clipboard::clear_clipboard_probe_hook(); } #[test] fn coalesce_multiline_paste_without_bracketed_paste() { let events = vec![ press(KeyCode::Char('a')), press(KeyCode::Char('b')), press(KeyCode::Enter), press(KeyCode::Char('c')), press(KeyCode::Char('d')), ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); assert_eq!(result[0], Event::Paste("ab\ncd".to_string())); } #[test] fn coalesce_filters_release_events() { // Press+Release pairs (Windows Terminal, Kitty) must not break runs. let events = vec![ press(KeyCode::Char('a')), release(KeyCode::Char('a')), press(KeyCode::Char('b')), release(KeyCode::Char('b')), press(KeyCode::Enter), release(KeyCode::Enter), press(KeyCode::Char('c')), release(KeyCode::Char('c')), ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); assert_eq!(result[0], Event::Paste("ab\nc".to_string())); } #[test] fn coalesce_preserves_shifted_chars() { let events = vec![ press_shift(KeyCode::Char('H')), press(KeyCode::Char('i')), press(KeyCode::Enter), press_shift(KeyCode::Char('B')), press(KeyCode::Char('y')), press(KeyCode::Char('e')), ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); assert_eq!(result[0], Event::Paste("Hi\nBye".to_string())); } #[test] fn coalesce_below_threshold_no_change() { let events = vec![press(KeyCode::Char('a')), press(KeyCode::Enter)]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 2); assert!(matches!(&result[0], Event::Key(ke) if ke.code == KeyCode::Char('a'))); assert!(matches!(&result[1], Event::Key(ke) if ke.code == KeyCode::Enter)); } #[test] fn coalesce_no_enter_no_change() { // No Enter in the run — no premature-send risk. let events = vec![ press(KeyCode::Char('h')), press(KeyCode::Char('e')), press(KeyCode::Char('l')), press(KeyCode::Char('l')), press(KeyCode::Char('o')), ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 5); for ev in &result { assert!(matches!(ev, Event::Key(_))); } } #[test] fn coalesce_only_enters_no_change() { // All-Enter runs must not coalesce (held Enter key repeat). let events = vec![ press(KeyCode::Enter), press(KeyCode::Enter), press(KeyCode::Enter), press(KeyCode::Enter), ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 4); } #[test] fn coalesce_preserves_non_key_events() { let events = vec![ Event::Resize(80, 24), press(KeyCode::Char('a')), press(KeyCode::Enter), press(KeyCode::Char('b')), Event::Resize(100, 30), ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 3); assert!(matches!(&result[0], Event::Resize(80, 24))); assert_eq!(result[1], Event::Paste("a\nb".to_string())); assert!(matches!(&result[2], Event::Resize(100, 30))); } #[test] fn coalesce_ctrl_key_breaks_run() { let events = vec![ press(KeyCode::Char('a')), press(KeyCode::Char('b')), press_ctrl(KeyCode::Char('c')), press(KeyCode::Enter), press(KeyCode::Char('d')), ]; let result = coalesce_rapid_keys(events); // "ab" (2, no Enter) | Ctrl+C | "\nd" (2) — both runs below threshold. assert_eq!(result.len(), 5); } #[test] fn coalesce_tabs_in_pasted_code() { let events = vec![ press(KeyCode::Char('i')), press(KeyCode::Char('f')), press(KeyCode::Enter), press(KeyCode::Tab), press(KeyCode::Char('x')), ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); assert_eq!(result[0], Event::Paste("if\n\tx".to_string())); } #[test] fn coalesce_exactly_at_threshold() { let events = vec![ press(KeyCode::Char('a')), press(KeyCode::Enter), press(KeyCode::Char('b')), ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); assert_eq!(result[0], Event::Paste("a\nb".to_string())); } #[test] fn coalesce_type_then_submit_not_coalesced() { // Enter is the LAST event — "type + submit", not paste. let events = vec![ press(KeyCode::Char('a')), press(KeyCode::Char('b')), press(KeyCode::Char('c')), press(KeyCode::Enter), ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 4); assert!(matches!(&result[3], Event::Key(ke) if ke.code == KeyCode::Enter)); } #[test] fn fragmented_paste_merged_with_keys() { // Event::Paste mixed with key events — merge into one paste. let events = vec![ Event::Paste("real paste".into()), press(KeyCode::Char('a')), press(KeyCode::Enter), press(KeyCode::Char('b')), ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); assert_eq!(result[0], Event::Paste("real pastea\nb".to_string())); } #[test] fn coalesce_single_event_passthrough() { let events = vec![press(KeyCode::Enter)]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); assert!(matches!(&result[0], Event::Key(_))); } #[test] fn coalesce_empty_input() { let result = coalesce_rapid_keys(vec![]); assert!(result.is_empty()); } // ── Multi-newline coalescing tests ─────────────────────────────── #[test] fn coalesce_three_lines() { // "foo\nbar\nbaz" — 3 lines, 2 newlines. let events = vec![ press(KeyCode::Char('f')), press(KeyCode::Char('o')), press(KeyCode::Char('o')), press(KeyCode::Enter), press(KeyCode::Char('b')), press(KeyCode::Char('a')), press(KeyCode::Char('r')), press(KeyCode::Enter), press(KeyCode::Char('b')), press(KeyCode::Char('a')), press(KeyCode::Char('z')), ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); assert_eq!(result[0], Event::Paste("foo\nbar\nbaz".to_string())); } #[test] fn coalesce_four_lines_trailing_newline() { // "a\nb\nc\nd\n" — 4 lines + trailing newline. let events = vec![ press(KeyCode::Char('a')), press(KeyCode::Enter), press(KeyCode::Char('b')), press(KeyCode::Enter), press(KeyCode::Char('c')), press(KeyCode::Enter), press(KeyCode::Char('d')), press(KeyCode::Enter), ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); assert_eq!(result[0], Event::Paste("a\nb\nc\nd\n".to_string())); } // ── should_extend_for_paste tests ─────────────────────────────── #[test] fn extend_triggered_with_single_pasteable_key() { let events = vec![press(KeyCode::Char('a'))]; assert!(should_extend_for_paste(&events)); } #[test] fn extend_triggered_with_enter_key() { let events = vec![press(KeyCode::Enter)]; assert!(should_extend_for_paste(&events)); } #[test] fn extend_not_triggered_with_bracketed_paste() { let events = vec![ Event::Paste("hello".into()), press(KeyCode::Char('a')), press(KeyCode::Enter), press(KeyCode::Char('b')), ]; assert!(!should_extend_for_paste(&events)); } #[test] fn extend_not_triggered_with_only_non_pasteable() { let events = vec![Event::Resize(80, 24)]; assert!(!should_extend_for_paste(&events)); } // ── merge_paste_fragments tests ───────────────────────────────── #[test] fn merge_paste_and_key_fragments() { // Fragmented bracketed paste: Event::Paste + loose key events. let events = vec![ Event::Paste("hello\nwor".into()), press(KeyCode::Char('l')), press(KeyCode::Char('d')), ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); assert_eq!(result[0], Event::Paste("hello\nworld".to_string())); } #[test] fn merge_multiple_paste_fragments() { let events = vec![ Event::Paste("aa\n".into()), Event::Paste("bb\n".into()), press(KeyCode::Char('c')), ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); assert_eq!(result[0], Event::Paste("aa\nbb\nc".to_string())); } #[test] fn merge_preserves_non_key_events() { let events = vec![ Event::Paste("hello".into()), Event::Resize(80, 24), press(KeyCode::Char('x')), ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 3); assert_eq!(result[0], Event::Paste("hello".to_string())); assert!(matches!(result[1], Event::Resize(80, 24))); assert_eq!(result[2], Event::Paste("x".to_string())); } #[test] fn merge_skips_release_events() { let events = vec![ Event::Paste("ab".into()), press(KeyCode::Char('c')), release(KeyCode::Char('c')), ]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); assert_eq!(result[0], Event::Paste("abc".to_string())); } #[test] fn pure_paste_no_merge_needed() { let events = vec![Event::Paste("hello\nworld".into())]; let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); assert_eq!(result[0], Event::Paste("hello\nworld".to_string())); } // ── is_pasteable_key_event filtering tests ───────────────────────── #[test] fn pasteable_rejects_mouse_events() { use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; let ev = Event::Mouse(MouseEvent { kind: MouseEventKind::Moved, column: 10, row: 5, modifiers: KeyModifiers::NONE, }); assert!(!is_pasteable_key_event(&ev)); let click = Event::Mouse(MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), column: 0, row: 0, modifiers: KeyModifiers::NONE, }); assert!(!is_pasteable_key_event(&click)); } #[test] fn pasteable_rejects_focus_events() { assert!(!is_pasteable_key_event(&Event::FocusGained)); assert!(!is_pasteable_key_event(&Event::FocusLost)); } #[test] fn pasteable_rejects_release_events() { assert!(!is_pasteable_key_event(&release(KeyCode::Char('a')))); assert!(!is_pasteable_key_event(&release(KeyCode::Enter))); } #[test] fn pasteable_rejects_resize() { assert!(!is_pasteable_key_event(&Event::Resize(80, 24))); } #[test] fn pasteable_rejects_repeat_events() { let ev = Event::Key(KeyEvent { code: KeyCode::Char('a'), modifiers: KeyModifiers::NONE, kind: KeyEventKind::Repeat, state: KeyEventState::NONE, }); assert!(!is_pasteable_key_event(&ev)); } #[test] fn pasteable_accepts_valid_key_presses() { assert!(is_pasteable_key_event(&press(KeyCode::Char('a')))); assert!(is_pasteable_key_event(&press_shift(KeyCode::Char('A')))); assert!(is_pasteable_key_event(&press(KeyCode::Enter))); assert!(is_pasteable_key_event(&press(KeyCode::Tab))); } #[test] fn extend_not_triggered_with_only_mouse_and_focus() { use crossterm::event::{MouseEvent, MouseEventKind}; let events = vec![ Event::Mouse(MouseEvent { kind: MouseEventKind::Moved, column: 10, row: 5, modifiers: KeyModifiers::NONE, }), Event::FocusGained, ]; assert!(!should_extend_for_paste(&events)); } #[test] fn extend_triggered_only_when_key_present_in_mixed_batch() { use crossterm::event::{MouseEvent, MouseEventKind}; let events = vec![ Event::Mouse(MouseEvent { kind: MouseEventKind::Moved, column: 0, row: 0, modifiers: KeyModifiers::NONE, }), press(KeyCode::Char('a')), Event::FocusLost, ]; assert!(should_extend_for_paste(&events)); } #[test] fn coalesce_mouse_events_interleaved_with_paste_chars() { // Simulates the batch produced by the fixed detect_paste: // a key press followed by mouse events. The mouse events // should not prevent the key from being processed. use crossterm::event::{MouseEvent, MouseEventKind}; let events = vec![ press(KeyCode::Char('a')), Event::Mouse(MouseEvent { kind: MouseEventKind::Moved, column: 10, row: 5, modifiers: KeyModifiers::NONE, }), Event::Mouse(MouseEvent { kind: MouseEventKind::Moved, column: 11, row: 5, modifiers: KeyModifiers::NONE, }), ]; let result = coalesce_rapid_keys(events); // Below coalesce threshold, all events pass through unchanged. assert_eq!(result.len(), 3); assert!(matches!(&result[0], Event::Key(ke) if ke.code == KeyCode::Char('a'))); assert!(matches!(&result[1], Event::Mouse(_))); assert!(matches!(&result[2], Event::Mouse(_))); } #[test] fn coalesce_mouse_breaks_key_run_preserves_events() { // A genuine paste batch that also collected mouse events. // The paste chars should still coalesce; mouse events are preserved. use crossterm::event::{MouseEvent, MouseEventKind}; let events = vec![ press(KeyCode::Char('a')), press(KeyCode::Char('b')), press(KeyCode::Enter), Event::Mouse(MouseEvent { kind: MouseEventKind::Moved, column: 5, row: 3, modifiers: KeyModifiers::NONE, }), press(KeyCode::Char('c')), ]; let result = coalesce_rapid_keys(events); // The mouse event breaks the key run: [a, b, Enter] (3 keys, but // Enter is last in that sub-run → no char after Enter → not coalesced), // then [mouse], then [c] (1 key). assert_eq!(result.len(), 5); } // ── Windows path-shape coalescing (drag-drop without bracketed paste) ─ // // Windows-gated: the path-shape branch only exists on Windows // (other platforms reliably get bracketed paste for drag-drop). #[cfg(target_os = "windows")] fn press_run(text: &str) -> Vec { text.chars().map(|c| press(KeyCode::Char(c))).collect() } /// Smoke test across every anchor variant the branch should match: /// drive-letter (both separators), UNC, Unix absolute, `file://`, /// and the Windows-Terminal-quoted form for paths with spaces. #[cfg(target_os = "windows")] #[test] fn coalesce_path_shape_matches_each_anchor() { for input in [ r"C:\foo.png", "C:/foo.png", r"\\srv\share\a.png", "/Users/a/b.png", "file:///tmp/x.png", "\"C:\\My Pics\\a.png\"", ] { let result = coalesce_rapid_keys(press_run(input)); assert_eq!(result.len(), 1, "input {input:?} should coalesce"); assert_eq!(result[0], Event::Paste(input.to_string())); } } /// Below-threshold path-shape (< 8 chars) and non-path prose of any /// length must NOT coalesce — keep typed editing intact. #[cfg(target_os = "windows")] #[test] fn coalesce_path_shape_rejects_short_or_non_path() { let short = "/foo.tx"; // 7 chars, below PATH_COALESCE_THRESHOLD assert!( coalesce_rapid_keys(press_run(short)) .iter() .all(|e| matches!(e, Event::Key(_))) ); let prose = "helloworld"; // 10 chars, no path anchor assert!( coalesce_rapid_keys(press_run(prose)) .iter() .all(|e| matches!(e, Event::Key(_))) ); } /// `:` in a US-layout drive-letter path arrives as Shift+`;`; /// `is_pasteable_key_event` accepts SHIFT so the run must assemble /// cleanly. #[cfg(target_os = "windows")] #[test] fn coalesce_path_shape_handles_shift_modifier() { let mut events = vec![press(KeyCode::Char('C'))]; events.push(press_shift(KeyCode::Char(':'))); events.extend(press_run(r"\foo.png")); let result = coalesce_rapid_keys(events); assert_eq!(result.len(), 1); assert_eq!(result[0], Event::Paste(r"C:\foo.png".to_string())); } }