//! Application entry point and terminal management. //! //! Submodule overview: //! - [`actions`] — Action, Effect, TaskResult enums //! - [`agent`] — AgentSession, AgentId, TurnState (business types) //! - [`agent_view`] — AgentView (per-agent view-model: input + draw) //! - [`app_view`] — AppView (root component: input routing + draw) //! - [`dispatch`] — Action → state mutation + Vec (sync, testable) //! - [`effects`] — Effect → async task spawning //! - [`acp_handler`] — ACP notification routing //! - [`event_loop`] — biased tokio::select! loop pub mod actions; pub mod agent; pub mod agent_view; pub mod app_view; pub mod bundle; pub mod cli; pub use crate::link_opener; /// Off-thread full-file syntax highlight upgrade for edit diffs. pub mod edit_highlight_worker; /// Off-thread Mermaid diagram render worker (out of process) + per-session cache. pub mod mermaid_worker; pub use kigi_prompt_queue as prompt_queue; mod acp_handler; mod csi_filter; mod dispatch; /// Display-refresh probe + motion cadence + terminal telemetry at startup. mod display_refresh_startup; mod effects; mod event_loop; mod foreign_sessions; mod inline_edit; #[cfg(all(test, unix))] mod leader_cluster; mod modals; mod mouse; mod queue_edit; pub mod roster; pub(crate) mod screen_mode_relaunch; pub mod session_startup; mod signal_handler; pub mod status_blocks; pub mod subagent; mod turn_completion; mod xt_filter; pub(crate) use crate::terminal::kitty_flags_pushed; pub use cli::{ AgentArgs, AgentCmd, Command, LeaderArgs, LeaderMgmtArgs, LeaderMgmtCommand, LeaderTargetArgs, OutputFormat, PagerArgs, ServeArgs, WrapArgs, }; use crossterm::cursor::{self, SetCursorStyle}; use crossterm::event; use crossterm::execute; use crossterm::terminal::{ self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, SetTitle, }; pub use foreign_sessions::ForeignScanCoordinator; pub(crate) use foreign_sessions::{ badge_for_picker_source, foreign_tool_display_label, is_foreign_picker_source, }; use kigi_shell::util::config; use ratatui::backend::CrosstermBackend; use std::io::{self, Write}; use std::panic; use std::sync::atomic::{AtomicBool, Ordering}; use tokio_util::sync::CancellationToken; /// Tracks the extra Kitty keyboard layer pushed while the `/gboom` game is /// open (see [`push_gboom_keyboard_flags`]). Kept separate from /// `KITTY_FLAGS_PUSHED` so teardown pops both, in LIFO order. static GBOOM_KEYBOARD_PUSHED: AtomicBool = AtomicBool::new(false); /// While the `/gboom` game owns input, additionally request /// `REPORT_ALL_KEYS_AS_ESCAPE_CODES` so plain letter keys (WASD) emit /// release events — required to track several keys held at once. No-op /// unless the Kitty keyboard protocol is active. Balanced by /// [`pop_gboom_keyboard_flags`] (and by `restore_terminal` on teardown). pub(crate) fn push_gboom_keyboard_flags() { if !kitty_flags_pushed() || GBOOM_KEYBOARD_PUSHED.swap(true, Ordering::AcqRel) { return; } let flags = event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES | event::KeyboardEnhancementFlags::REPORT_EVENT_TYPES | event::KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES; kigi_shell::util::with_locked_stderr(|stderr| { let _ = execute!(stderr, event::PushKeyboardEnhancementFlags(flags)); }); } /// Pop the extra keyboard layer pushed by [`push_gboom_keyboard_flags`]. pub(crate) fn pop_gboom_keyboard_flags() { if GBOOM_KEYBOARD_PUSHED.swap(false, Ordering::AcqRel) { kigi_shell::util::with_locked_stderr(|stderr| { let _ = execute!(stderr, event::PopKeyboardEnhancementFlags); }); } } /// Tracks whether mouse capture (the five DEC modes enabled by /// crossterm `EnableMouseCapture` + bracketed paste) is currently active. pub(crate) static MOUSE_CAPTURE_ENABLED: AtomicBool = AtomicBool::new(false); /// Whether minimal was auto-selected solely because the terminal leaks mouse /// reports as raw text (JediTerm/Windows) and the user expressed no preference. /// Gates the idle-hint "auto-set" note so it never misleads users who chose /// minimal themselves. static MINIMAL_AUTO_SET_FOR_MOUSE_LEAK: AtomicBool = AtomicBool::new(false); /// See [`MINIMAL_AUTO_SET_FOR_MOUSE_LEAK`]. pub fn minimal_auto_set_for_mouse_leak() -> bool { MINIMAL_AUTO_SET_FOR_MOUSE_LEAK.load(Ordering::Acquire) } /// Set after a `/minimal` re-exec that actually stayed minimal (idle-status cue). static MINIMAL_SHOW_SWITCH_BACK_TO_FULLSCREEN: AtomicBool = AtomicBool::new(false); pub fn minimal_show_switch_back_to_fullscreen() -> bool { MINIMAL_SHOW_SWITCH_BACK_TO_FULLSCREEN.load(Ordering::Acquire) } #[cfg(any(test, feature = "test-support"))] pub fn set_minimal_show_switch_back_to_fullscreen_for_test(on: bool) { MINIMAL_SHOW_SWITCH_BACK_TO_FULLSCREEN.store(on, Ordering::Release); } /// Whether startup actually applied a forced cursor style. Teardown (and the /// panic hook, which can't thread parameters) resets the style only when /// true: under inherit, `0 q` would clobber a shell-chosen style. pub(crate) static CURSOR_STYLE_FORCED: AtomicBool = AtomicBool::new(false); /// Whether this process runs the minimal (scrollback-native) screen mode. /// Set once by [`apply_screen_mode_globals`] from the *effective* mode. /// /// Exists for the few places that need minimal-mode **behavior** (input /// semantics, state mutations) but sit below `AppView` and cannot see /// `AppView::screen_mode` (e.g. `AgentView::handle_input`). Do NOT use the /// styling globals (`modal_window::embedded()`, `scrollbar hidden`, …) for /// behavior gating: those are deliberately mode-agnostic render toggles, and a /// future embedded host flipping them must not inherit minimal's key remaps or /// scrollback writes. static MINIMAL_MODE_ACTIVE: AtomicBool = AtomicBool::new(false); /// Whether the process runs in minimal (scrollback-native) mode. See /// [`MINIMAL_MODE_ACTIVE`]; prefer `AppView::screen_mode.is_minimal()` wherever /// the screen mode is already in reach. pub(crate) fn minimal_mode_active() -> bool { MINIMAL_MODE_ACTIVE.load(Ordering::Acquire) } /// Test-only override for [`minimal_mode_active`] (unit tests exercising /// minimal-gated input paths without a terminal). Save/restore around use — /// this is process-global state. #[cfg(test)] pub(crate) fn set_minimal_mode_active_for_test(on: bool) { MINIMAL_MODE_ACTIVE.store(on, Ordering::Release); } /// Whether the opt-in mouse-reporting toggle feature is enabled /// (`[ui] mouse_reporting_toggle` / `KIGI_MOUSE_REPORTING_TOGGLE`). Seeded once /// at startup; gates both the `Ctrl+R` shortcut registration and the /// `/toggle-mouse-reporting` slash command's visibility/execution. pub(crate) static MOUSE_REPORTING_TOGGLE_ENABLED: AtomicBool = AtomicBool::new(false); /// Read the cached opt-in mouse-reporting toggle flag (see /// [`MOUSE_REPORTING_TOGGLE_ENABLED`]). Set once at startup from layered config. pub(crate) fn mouse_reporting_toggle_enabled() -> bool { MOUSE_REPORTING_TOGGLE_ENABLED.load(Ordering::Acquire) } /// Sticky banner shown while mouse reporting is off, telling the user how to /// turn it back on. The advertised invocation depends on focus: `Ctrl+R` only /// works from scrollback, so the prompt-focused variant points at the /// `/toggle-mouse-reporting` command (which toggles from any pane). The banner /// is stored in the scrollback form; `AgentView::active_toast_message` swaps to /// the prompt form at render time when the prompt is focused. pub(crate) const MOUSE_OFF_HINT_SCROLLBACK: &str = "Ctrl+r to enable mouse reporting and restore TUI features"; pub(crate) const MOUSE_OFF_HINT_PROMPT: &str = "/toggle-mouse-reporting to enable mouse reporting and restore TUI features"; /// Terminal type for the pager. /// /// Uses [`kigi_ratatui_inline::Terminal`] instead of stock `ratatui::Terminal` /// because our `flush()` returns `bool` indicating whether any cells actually /// changed. This lets [`crate::render::draw::draw_frame`] skip cursor escape /// sequences on frames with empty diffs (e.g., off-screen animation ticks), /// preserving the cursor blink timer. See [`crate::render::draw`] for details. /// /// The backend writes to a [`TermWriter`](crate::render::draw::TermWriter) /// that buffers frame data in memory and sends it to a dedicated writer /// thread via a channel. The writer thread performs the actual blocking /// `write()` to stderr / the pty fd, keeping the tokio event loop free /// from pty back-pressure (e.g. when Ghostty is busy with another pane). pub use crate::render::draw::PagerTerminal; /// Whether the pager uses the alternate screen (fullscreen) or stays inline. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ScreenMode { Fullscreen, Inline, /// Scrollback-native (experimental, `--minimal`): finalized blocks are /// printed into the terminal's native scrollback via `insert_before`, with /// a small pinned live region for the prompt, status, and running turn. /// /// All minimal-mode rendering lives in the sibling `kigi-pager-minimal` /// crate. This crate only holds the seam: `crate::minimal_hook` (dispatch /// into minimal's `draw`/transcript), `crate::minimal_api` (the read surface /// minimal consumes), and `AppView::minimal_state`. If you don't work on /// minimal, treat this variant as opaque — the fullscreen/inline paths are /// unaffected. Minimal, } impl ScreenMode { pub(crate) fn is_fullscreen(self) -> bool { matches!(self, Self::Fullscreen) } /// Whether this is the experimental scrollback-native minimal mode. pub(crate) fn is_minimal(self) -> bool { matches!(self, Self::Minimal) } /// Stable wire label for the `_meta.screenMode` prompt-telemetry field /// (headless sends `"headless"`). Values are pinned by the telemetry /// allowlist (`kigi-telemetry`'s `KNOWN_SCREEN_MODES`); renaming one /// silently collapses it to `"other"` on the external stream. pub(crate) fn meta_label(self) -> &'static str { match self { Self::Fullscreen => "fullscreen", Self::Inline => "inline", Self::Minimal => "minimal", } } } /// Install the process-wide render globals that depend on the screen mode. /// /// Consolidates every "minimal behaves differently here" toggle into one place /// so the rest of startup (and any future contributor) doesn't have to sprinkle /// `is_minimal()` checks through `run`. All of these globals are no-ops outside /// minimal (they default to the full-TUI behavior), so calling this for every /// mode is safe and keeps the effective-mode source of truth singular. fn apply_screen_mode_globals(screen_mode: ScreenMode) { let minimal = screen_mode.is_minimal(); MINIMAL_MODE_ACTIVE.store(minimal, Ordering::Release); crate::terminal::image::set_inline_overlay_force_off(minimal); crate::views::modal_window::set_embedded(minimal); crate::render::scrollbar::set_scrollbars_hidden(minimal); crate::theme::cache::set_terminal_native_lock(minimal); } /// Startup theme state for the *requested* screen mode — step 1 of the /// two-phase startup theme handshake (step 2: [`finish_theme_after_probe`]). /// Must run before `init_terminal`, whose `apply_cursor_color()` reads the /// state installed here. fn engage_startup_theme(screen_mode: ScreenMode) { if screen_mode.is_minimal() { crate::theme::cache::set_terminal_native_lock(true); } else { let initial_theme = crate::theme::cache::resolve_initial_theme(); crate::theme::cache::set(initial_theme); } } /// Step 2 of the startup theme handshake: if a `--minimal` start was /// downgraded to Inline by `init_terminal`'s probe, resolve the regular /// theme that [`engage_startup_theme`] skipped. No-op otherwise. fn finish_theme_after_probe(requested_minimal: bool, effective_mode: ScreenMode) { if requested_minimal && !effective_mode.is_minimal() { let late_theme = crate::theme::cache::resolve_initial_theme_no_osc11(); crate::theme::cache::set(late_theme); crate::theme::apply_cursor_color(); tracing::info!(?late_theme, "minimal downgrade: resolved regular theme"); } } /// Info about the active session at exit time, used for the resume hint. /// /// Wrapped in a struct so additional fields (e.g., cwd, model) can be added /// without changing the return type. pub(crate) struct ExitInfo { pub session_id: String, pub minimal: bool, } /// Resolve leader mode → `(use_leader, policy_disable_reason)`. /// /// `policy_disable_reason` is `Some("config"|"remote")` only when leader mode is /// *definitively* off by policy (local `use_leader = false`, or remote /// `leader_mode` fetched as `false`). Unknown remote state (`None` / prefetch /// timeout), the default, `--no-leader`, and ineligibility are `None` — never /// reclaim a leader on an unknown signal. pub fn resolve_use_leader( leader_flag: bool, no_leader_flag: bool, raw_config: &toml::Value, eligible: bool, ) -> (bool, Option<&'static str>) { if no_leader_flag { return (false, None); } if leader_flag { return (true, None); } if !eligible { return (false, None); } if let Some(v) = config::use_leader_from_toml_opt(raw_config) { return (v, (!v).then_some("config")); } (false, None) } /// First non-blank of CLI > env > config (precedence + blank-skip). `None` → /// nothing set; `acp::initialize` canonicalizes and applies the default. fn resolve_hunk_tracker_mode( cli: Option<&str>, env: Option<&str>, config: Option<&str>, ) -> Option { [cli, env, config] .into_iter() .flatten() .map(str::trim) .find(|s| !s.is_empty()) .map(str::to_owned) } /// Main entry point: connect to agent, init terminal, run event loop, restore. /// /// If a session ID is provided via `--resume` / `--load` / `--continue`, the /// pager skips the welcome screen and immediately loads that session (replaying /// its history). Sessions not found locally are restored from remote storage. /// /// Returns `Ok(true)` when the user accepted a pending update. The caller /// should print a message telling the user to relaunch `kigi`. pub async fn run( args: PagerArgs, bg_update_rx: Option< tokio::sync::oneshot::Receiver>, >, ) -> anyhow::Result { kigi_tty_utils::redirect_native_stderr(); let screen_mode_override = screen_mode_relaunch::take_screen_mode_env_override(); let cancel = CancellationToken::new(); let startup_start = std::time::Instant::now(); let raw_config = kigi_shell::config::load_effective_config() .map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?; let kimi_code_config = match kigi_shell::agent::config::Config::new_from_toml_cfg(&raw_config) { Ok(c) => c.kimi_code_config, Err(e) => { tracing::warn!( error = % e, "failed to parse config for auth refresh, using defaults" ); kigi_shell::auth::KimiCodeConfig::default() } }; let refreshed_auth = kigi_shell::auth::try_ensure_fresh_auth(&kimi_code_config).await; // Fire-and-forget model-catalog warmup; nothing joins the handle now that // the xAI settings fetch it used to carry is gone. drop(kigi_shell::agent::models::start_early_prefetch_with_auth( refreshed_auth, )); kigi_shell::agent::mvp_agent::warm_async_http_client(); tokio::task::spawn_blocking(|| {}); if let Ok(cwd) = std::env::current_dir() { crate::git_info::populate_from_cwd_async(cwd); } let raw_config = kigi_shell::config::load_effective_config() .map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?; let prefetch_elapsed = startup_start.elapsed(); let (use_leader, policy_disable_reason) = resolve_use_leader(args.leader, args.no_leader, &raw_config, true); tracing::info!( use_leader, ?policy_disable_reason, prefetch_ms = prefetch_elapsed.as_millis() as u64, "pager TUI leader mode resolved" ); if session_startup::chat_mode_conflicts_with_leader(args.chat(), use_leader) { anyhow::bail!("{}", session_startup::CHAT_MODE_LEADER_CONFLICT); } if args.trust { match std::env::current_dir() { Ok(cwd) => kigi_shell::agent::folder_trust::grant_folder_trust(&cwd), Err(e) => { tracing::warn!( error = % e, "--trust: failed to resolve cwd; folder not trusted" ) } } } if let Some(reason) = policy_disable_reason { tokio::spawn(kigi_shell::leader::kill_stale_reachable_leaders(reason)); } if let Some(err) = session_startup::chat_mode_flag_conflict(args.chat(), args.fork_session, args.restore_code) { anyhow::bail!("{err}"); } let intent = args .session_startup_intent() .map_err(|e| anyhow::anyhow!("{e}"))?; let materialized = session_startup::materialize_startup( session_startup::MaterializeCtx::from_pager_args(&args), intent, ) .await?; if args.chat() && let session_startup::MaterializedStartup::Resume { session_id, .. } = &materialized { let cwd = std::env::current_dir().unwrap_or_default(); if session_startup::chat_mode_refuses_local_build_load(true, false, session_id, &cwd) { anyhow::bail!( "{} (session id: {session_id})", session_startup::CHAT_MODE_LOCAL_BUILD_REFUSAL ); } } let mut session_title = match &materialized { session_startup::MaterializedStartup::Resume { title, .. } | session_startup::MaterializedStartup::Fork { parent_title: title, .. } => title.clone(), _ => None, }; let title_lookup_id = match &materialized { session_startup::MaterializedStartup::Resume { session_id, .. } => { Some(session_id.as_str()) } session_startup::MaterializedStartup::Fork { parent_session_id, .. } => Some(parent_session_id.as_str()), _ => None, }; if session_title.is_none() && !args.chat() && let Some(id) = title_lookup_id { let summaries = kigi_shell::session::persistence::list_summaries(None).await?; if let Some(s) = summaries.iter().find(|s| s.info.id.0.as_ref() == id) && let Some(title) = s.display_title_opt() { session_title = Some(title); } } let session_cwd = match &materialized { session_startup::MaterializedStartup::Resume { original_cwd, .. } | session_startup::MaterializedStartup::Fork { parent_cwd: original_cwd, .. } => original_cwd.clone(), _ => None, }; let env_hunk_tracker_mode = std::env::var("KIGI_HUNK_TRACKER").ok(); let config_hunk_tracker_mode = raw_config .get("ui") .and_then(|ui| ui.get("hunk_tracker_mode")) .and_then(|v| v.as_str()); let hunk_tracker_mode = resolve_hunk_tracker_mode( args.hunk_tracker_mode.as_deref(), env_hunk_tracker_mode.as_deref(), config_hunk_tracker_mode, ); let remote_permission_mode = None; let launch_yolo = kigi_shell::util::config::effective_yolo_for_launch( args.yolo, args.permission_mode_flag.as_deref(), remote_permission_mode, ); let launch_auto = kigi_shell::util::config::effective_auto_for_launch( args.yolo, args.permission_mode_flag.as_deref(), remote_permission_mode, ); let connect_flags = crate::acp::ConnectFlags { subagents: !args.no_subagents, experimental_memory: args.experimental_memory, no_memory: args.no_memory, disable_web_search: args.disable_web_search, todo_gate: args.todo_gate, laziness_debug_log: None, storage_mode: args.storage_mode.clone(), client_identifier: args.client_identifier.clone(), hunk_tracker_mode, terminal: args.terminal, fs_read: args.fs_read, fs_write: args.fs_write, installer: args.installer.clone(), remote_settings: None, system_prompt_override: args.system_prompt_override.clone(), rules: args.rules.clone(), reasoning_effort_override: args .reasoning_effort .as_deref() .and_then(kigi_shell::sampling::types::parse_canonical_effort_token), permission_rules: crate::headless::parse_permission_rules_lenient( &args.allow_rules, &args.deny_rules, ), default_yolo_mode: launch_yolo.yolo, default_auto_mode: launch_auto && !launch_yolo.yolo, }; let connection = if use_leader { let conn = crate::acp::connect_via_leader(&cancel, connect_flags, &raw_config).await?; tracing::info!( elapsed_ms = startup_start.elapsed().as_millis() as u64, "Connected via leader" ); conn } else { let conn = crate::acp::connect(&cancel, connect_flags).await?; tracing::info!( elapsed_ms = startup_start.elapsed().as_millis() as u64, "Connected directly (non-leader)" ); conn }; let mut config_watcher = crate::appearance::ConfigWatcher::start().await?; let alt_screen_config_mode = config_watcher.current().alt_screen; let term_ctx = crate::terminal::terminal_context(); let is_control_mode = crate::terminal::detect_tmux_control_mode(term_ctx); let alt_screen_wants_fullscreen = crate::terminal::determine_alt_screen_policy( args.no_alt_screen, alt_screen_config_mode, term_ctx, is_control_mode, ); let config_screen_mode = raw_config .get("ui") .and_then(|ui| ui.get("screen_mode")) .and_then(|v| v.as_str()); let auto_minimal_mouse_leak = term_ctx.mouse_reporting_leaks_as_raw_text(); let explicit_minimal = screen_mode_relaunch::effective_minimal_preference( args.minimal, args.fullscreen, config_screen_mode, config_watcher.current().minimal, ); let screen_mode = screen_mode_relaunch::resolve_screen_mode( screen_mode_override, explicit_minimal.unwrap_or(auto_minimal_mouse_leak), alt_screen_wants_fullscreen, ); MINIMAL_AUTO_SET_FOR_MOUSE_LEAK.store( screen_mode.is_minimal() && explicit_minimal.is_none() && screen_mode_override.is_none(), Ordering::Release, ); let minimal = screen_mode.is_minimal(); let relaunched_into_minimal = screen_mode_override == Some(ScreenMode::Minimal); let relaunched_into_fullscreen = screen_mode_override == Some(ScreenMode::Fullscreen); tracing::info!( use_alt_screen = screen_mode.is_fullscreen(), minimal = screen_mode.is_minimal(), mouse_capture = ! screen_mode.is_minimal(), minimal_live_rows = config_watcher .current().minimal_live_rows, is_control_mode, no_alt_screen_cli = args .no_alt_screen, minimal_cli = args.minimal, fullscreen_cli = args.fullscreen, config_screen_mode = ? config_screen_mode, auto_minimal_mouse_leak, config_mode = ? alt_screen_config_mode, multiplexer = ? term_ctx.multiplexer, "resolved fullscreen policy" ); engage_startup_theme(screen_mode); let minimal_live_rows = config_watcher.current().minimal_live_rows; let (frame_tx, writer_sync, writer_thread) = crate::render::draw::spawn_writer_thread(); let cursor_blink = event_loop::load_initial_ui_config().cursor_blink; let (mut terminal, screen_mode) = init_terminal( screen_mode, minimal_live_rows, relaunched_into_minimal, frame_tx, writer_sync, cursor_blink, )?; MINIMAL_SHOW_SWITCH_BACK_TO_FULLSCREEN.store( relaunched_into_minimal && screen_mode.is_minimal(), Ordering::Release, ); apply_screen_mode_globals(screen_mode); finish_theme_after_probe(minimal, screen_mode); if let Some(ref t) = session_title { set_terminal_title(t); } let effective_args = PagerArgs { resume_session: None, load_session: None, continue_last_session: false, session_id: None, fork_session: false, ..args }; let term_state = event_loop::TerminalState { is_control_mode, screen_mode, relaunched_into_minimal, relaunched_into_fullscreen, initial_theme: crate::theme::cache::current_kind(), }; let result = event_loop::run( &mut terminal, connection, &mut config_watcher, &effective_args, session_cwd, None, term_state, materialized, bg_update_rx, ) .await; crate::unified_log::flush_blocking().await; let _ = restore_terminal(terminal, writer_thread, screen_mode); cancel.cancel(); kigi_tty_utils::global_process_scope().kill_all(); match result { Ok(run_result) => { if run_result.quit_for_update { return Ok(true); } if let Some(relaunch) = run_result.relaunch.as_ref() { if let Err(e) = screen_mode_relaunch::exec_screen_mode_relaunch( &relaunch.session_id, relaunch.minimal, ) { tracing::error!(error = % e, "screen-mode relaunch failed"); print_relaunch_failure_hint( &e, &relaunch.session_id, relaunch.minimal, &mut io::stderr(), ); } return Ok(false); } if let Some(info) = run_result.exit_info { print_exit_resume_hint(&info.session_id, info.minimal, &mut io::stderr()); } Ok(false) } Err(e) => Err(e), } } /// Plain-quit "Resume this session with…" lines (after terminal restore). /// Best-effort: closed-pane EIO/BrokenPipe must not panic (`panic = "abort"`). fn print_exit_resume_hint(session_id: &str, minimal: bool, w: &mut impl Write) { let _ = writeln!(w); let _ = writeln!(w, "Resume this session with:"); if minimal { let _ = writeln!(w, " kigi --minimal --resume {session_id}"); } else { let _ = writeln!(w, " kigi --resume {session_id}"); } } /// Screen-mode relaunch failure fallback (same quit tail as plain resume). fn print_relaunch_failure_hint( error: &impl std::fmt::Display, session_id: &str, want_minimal: bool, w: &mut impl Write, ) { let _ = writeln!(w, "Failed to relaunch in requested mode: {error}"); let _ = writeln!(w, "Resume this session with:"); let _ = writeln!( w, " {}", screen_mode_relaunch::screen_mode_relaunch_resume_hint(session_id, want_minimal), ); } /// Write raw CSI sequences to disable mouse tracking and bracketed paste. /// /// Best-effort: failures are silently ignored since this runs on teardown /// and panic paths where stderr may already be broken. fn disable_mouse_paste_raw() { kigi_shell::util::with_locked_stderr(|stderr| { let _ = stderr.write_all(kigi_crash_handler::terminal::MOUSE_PASTE_RESET); let _ = stderr.flush(); }); } /// Drain any pending terminal input events. /// /// External processes (SSH/GPG agents, etc.) may write to the TTY (e.g. "Enter /// encryption key:") before we take over. This helper drains the crossterm /// event queue so those characters don't appear as ghost text in the input /// field. fn drain_pending_events() { drain_pending_events_with_timeout(std::time::Duration::from_millis(0)); } fn drain_pending_events_with_timeout(timeout: std::time::Duration) { while crossterm::event::poll(timeout).unwrap_or(false) { if crossterm::event::read().is_err() { break; } } } /// Set the console output code page to UTF-8 and enable /// `ENABLE_VIRTUAL_TERMINAL_PROCESSING` on the stderr console handle. /// /// **Code page** — The pager outputs UTF-8 (Braille art in the logo, Powerline /// icons, box-drawing characters). On Windows the default console code page is /// a legacy OEM page (e.g. CP437), so multi-byte UTF-8 sequences are /// misinterpreted as individual single-byte characters, producing garbled /// output. Setting the output code page to 65001 (UTF-8) fixes this. /// /// **VTP on stderr** — Each console handle (stdin, stdout, stderr) has /// independent mode flags. `crossterm::enable_raw_mode()` sets flags on stdin /// only. Since the pager renders to stderr (via `TermWriter`), ANSI sequences /// for background colors (SGR 48;2;R;G;B), alternate screen, and cursor /// control must be processed by the stderr handle. Without the VTP flag the /// console silently drops background-color sequences while foreground colors /// work, producing the "text renders but backgrounds are missing" symptom. /// /// Best-effort: if any call fails (e.g. stderr is redirected to a file), /// the pager continues — rendering may be degraded but the TUI is still usable. #[cfg(windows)] fn configure_windows_console() { const STD_ERROR_HANDLE: u32 = 0xFFFF_FFF4u32; const ENABLE_PROCESSED_OUTPUT: u32 = 0x0001; const ENABLE_VIRTUAL_TERMINAL_PROCESSING: u32 = 0x0004; const CP_UTF8: u32 = 65001; unsafe extern "system" { fn GetStdHandle(nStdHandle: u32) -> *mut core::ffi::c_void; fn GetConsoleMode(hConsoleHandle: *mut core::ffi::c_void, lpMode: *mut u32) -> i32; fn SetConsoleMode(hConsoleHandle: *mut core::ffi::c_void, dwMode: u32) -> i32; fn SetConsoleOutputCP(wCodePageID: u32) -> i32; } unsafe { SetConsoleOutputCP(CP_UTF8); let handle = GetStdHandle(STD_ERROR_HANDLE); if handle.is_null() || handle == -1_isize as *mut _ { return; } let mut mode: u32 = 0; if GetConsoleMode(handle, &mut mode) == 0 { return; } let _ = SetConsoleMode( handle, mode | ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING, ); } } /// Native drag-to-select on legacy conhost windows (classic `powershell.exe` / /// `cmd.exe` console host — not Windows Terminal / Warp) is conhost's /// **QuickEdit** mode, controlled by console-input mode flags on the *stdin* /// handle, not by DEC private-mode escapes. /// /// Minimal mode's contract is "the terminal owns the mouse" (design K7), and /// on conhost merely *skipping* `EnableMouseCapture` is not enough: /// /// - crossterm's `EnableMouseCapture` is winapi-only on Windows /// (`is_ansi_code_supported() == false`): it **replaces** the stdin mode /// with `ENABLE_MOUSE_INPUT | ENABLE_EXTENDED_FLAGS | ENABLE_WINDOW_INPUT`. /// `ENABLE_EXTENDED_FLAGS` without `ENABLE_QUICK_EDIT_MODE` turns QuickEdit /// *off*. /// - `SetConsoleMode` state **outlives the process** for the console window, /// and teardown historically reset mouse state with ANSI sequences only — /// so one fullscreen/inline run left the window with QuickEdit off and /// `ENABLE_MOUSE_INPUT` on, breaking native drag-select for every later /// `--minimal` run in that same window ("works in a fresh cmd window but /// not in my PowerShell window"). /// - Some PowerShell shortcuts ship QuickEdit disabled per window title /// (`HKCU\Console\`), so even a pristine window may need it asserted. /// /// Modern terminals (Windows Terminal, Warp) select host-side and decide "app /// owns the mouse" from the DEC `?100x` escapes — which minimal never emits — /// so these conhost flags are inert there and asserting them is harmless. /// Everything is best-effort: if stdin is not a console, calls are no-ops. #[cfg(any(windows, test))] pub(crate) mod win_native_selection { const ENABLE_WINDOW_INPUT: u32 = 0x0008; const ENABLE_MOUSE_INPUT: u32 = 0x0010; const ENABLE_QUICK_EDIT_MODE: u32 = 0x0040; const ENABLE_EXTENDED_FLAGS: u32 = 0x0080; /// Stdin console mode for "terminal owns the mouse": QuickEdit on (with /// the extended-flags gate that makes it effective), app-side mouse /// reporting off, and window-resize events on (parity with the capture /// path — `WINDOW_BUFFER_SIZE_EVENT` is how resize reaches crossterm on /// conhost). All other bits are preserved. pub(crate) fn native_selection_mode(mode: u32) -> u32 { (mode & !ENABLE_MOUSE_INPUT) | ENABLE_EXTENDED_FLAGS | ENABLE_QUICK_EDIT_MODE | ENABLE_WINDOW_INPUT } #[cfg(windows)] pub(crate) use imp::{enable_native_selection, restore_stdin_mode}; #[cfg(windows)] mod imp { use std::sync::atomic::{AtomicU64, Ordering}; const STD_INPUT_HANDLE: u32 = 0xFFFF_FFF6u32; /// Stdin mode before the first `enable_native_selection`; `u64::MAX` /// means "never touched" (same sentinel scheme crossterm uses for its /// own capture snapshot). First writer wins, so repeated enables (e.g. /// `/mouse` toggles) keep the true original for teardown. static ORIGINAL_STDIN_MODE: AtomicU64 = AtomicU64::new(u64::MAX); unsafe extern "system" { fn GetStdHandle(nStdHandle: u32) -> *mut core::ffi::c_void; fn GetConsoleMode(hConsoleHandle: *mut core::ffi::c_void, lpMode: *mut u32) -> i32; fn SetConsoleMode(hConsoleHandle: *mut core::ffi::c_void, dwMode: u32) -> i32; } /// Read the stdin console handle + its current mode. `None` when /// stdin is redirected / not a console. fn stdin_console_mode() -> Option<(*mut core::ffi::c_void, u32)> { unsafe { let handle = GetStdHandle(STD_INPUT_HANDLE); if handle.is_null() || handle == -1_isize as *mut _ { return None; } let mut mode: u32 = 0; if GetConsoleMode(handle, &mut mode) == 0 { return None; } Some((handle, mode)) } } /// Assert the native-selection stdin mode (QuickEdit on, app mouse /// reporting off, resize events on), snapshotting the original mode /// once for [`restore_stdin_mode`]. pub(crate) fn enable_native_selection() { let Some((handle, mode)) = stdin_console_mode() else { return; }; let _ = ORIGINAL_STDIN_MODE.compare_exchange( u64::MAX, u64::from(mode), Ordering::AcqRel, Ordering::Acquire, ); let new_mode = super::native_selection_mode(mode); if new_mode != mode { unsafe { let _ = SetConsoleMode(handle, new_mode); } } } /// Restore the mode captured by the first `enable_native_selection` /// (no-op if it never ran). Teardown-only; consumes the snapshot so /// concurrent teardown paths (panic hook + restore_terminal) restore /// at most once. pub(crate) fn restore_stdin_mode() { let saved = ORIGINAL_STDIN_MODE.swap(u64::MAX, Ordering::AcqRel); let Ok(saved) = u32::try_from(saved) else { return; }; if let Some((handle, current)) = stdin_console_mode() { if current != saved { unsafe { let _ = SetConsoleMode(handle, saved); } } } } } #[cfg(test)] mod tests { use super::*; const ENABLE_PROCESSED_INPUT: u32 = 0x0001; const ENABLE_VIRTUAL_TERMINAL_INPUT: u32 = 0x0200; #[test] fn asserts_quick_edit_and_resize_clears_mouse_input() { let mode = native_selection_mode(ENABLE_MOUSE_INPUT); assert_eq!(mode & ENABLE_MOUSE_INPUT, 0, "app mouse reporting off"); assert_ne!(mode & ENABLE_QUICK_EDIT_MODE, 0, "QuickEdit on"); assert_ne!(mode & ENABLE_EXTENDED_FLAGS, 0, "extended-flags gate on"); assert_ne!(mode & ENABLE_WINDOW_INPUT, 0, "resize events on"); } #[test] fn preserves_unrelated_bits() { let input = ENABLE_PROCESSED_INPUT | ENABLE_VIRTUAL_TERMINAL_INPUT; let mode = native_selection_mode(input); assert_eq!(mode & input, input); } #[test] fn idempotent() { let once = native_selection_mode(ENABLE_MOUSE_INPUT | ENABLE_PROCESSED_INPUT); assert_eq!(native_selection_mode(once), once); } /// The crossterm capture mode (what a crashed prior run leaves behind) /// maps to a QuickEdit-on, reporting-off mode. #[test] fn recovers_from_stale_crossterm_capture_mode() { const CROSSTERM_ENABLE_MOUSE_MODE: u32 = ENABLE_MOUSE_INPUT | ENABLE_EXTENDED_FLAGS | ENABLE_WINDOW_INPUT; let mode = native_selection_mode(CROSSTERM_ENABLE_MOUSE_MODE); assert_eq!(mode & ENABLE_MOUSE_INPUT, 0); assert_ne!(mode & ENABLE_QUICK_EDIT_MODE, 0); } } } /// Startup cursor-style policy from `[ui].cursor_blink`: `Inherit` (the /// `None` default) emits no style escapes, so the terminal's configured /// cursor shape/blink survives; forcing one was reported as cursor flicker. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CursorStylePolicy { /// Leave the terminal's cursor style untouched (default). Inherit, /// Legacy: `EnableBlinking` + `SetCursorStyle::BlinkingBlock`. ForceBlinking, /// `DisableBlinking` + `SetCursorStyle::SteadyBlock`. ForceSteady, } /// Map the `[ui].cursor_blink` tri-state onto the startup policy. fn cursor_style_policy(cursor_blink: Option<bool>) -> CursorStylePolicy { match cursor_blink { None => CursorStylePolicy::Inherit, Some(true) => CursorStylePolicy::ForceBlinking, Some(false) => CursorStylePolicy::ForceSteady, } } /// Initialize the terminal for `mode`. Returns the live terminal handle and the /// *effective* screen mode, which may differ from the requested one: a /// `Minimal` request downgrades to `Inline` if the inline-viewport probe fails /// (its `insert_before` / `set_viewport_height` commit pipeline is a no-op on /// the `Viewport::Fixed` fallback, so minimal cannot function there). fn init_terminal( mode: ScreenMode, minimal_live_rows: u16, clear_main_screen: bool, frame_tx: std::sync::mpsc::Sender<Vec<u8>>, writer_sync: crate::render::draw::WriterSync, cursor_blink: Option<bool>, ) -> io::Result<(PagerTerminal, ScreenMode)> { kigi_crash_handler::enable_terminal_escape_restore(); terminal::enable_raw_mode()?; #[cfg(windows)] configure_windows_console(); let want_minimal = mode.is_minimal(); (move || -> io::Result<(PagerTerminal, ScreenMode)> { drain_pending_events(); set_terminal_title(""); if want_minimal && clear_main_screen { kigi_shell::util::with_locked_stderr(|stderr| { execute!( stderr, Clear(ClearType::All), Clear(ClearType::Purge), cursor::MoveTo(0, 0), ) })?; } if mode.is_fullscreen() { kigi_shell::util::with_locked_stderr(|stderr| execute!(stderr, EnterAlternateScreen))?; } #[cfg(windows)] if want_minimal { win_native_selection::enable_native_selection(); } kigi_shell::util::with_locked_stderr(|stderr| { if !want_minimal { execute!(stderr, event::EnableMouseCapture)?; } else if crate::terminal::terminal_context().mouse_reporting_leaks_as_raw_text() { let _ = stderr.write_all(kigi_crash_handler::terminal::MOUSE_TRACKING_RESET); } execute!( stderr, event::EnableFocusChange, event::EnableBracketedPaste, cursor::Hide, )?; let policy = cursor_style_policy(cursor_blink); match policy { CursorStylePolicy::Inherit => {} CursorStylePolicy::ForceBlinking => { execute!( stderr, cursor::EnableBlinking, SetCursorStyle::BlinkingBlock )?; } CursorStylePolicy::ForceSteady => { execute!(stderr, cursor::DisableBlinking, SetCursorStyle::SteadyBlock)?; } } CURSOR_STYLE_FORCED.store(policy != CursorStylePolicy::Inherit, Ordering::Release); io::Result::Ok(()) })?; MOUSE_CAPTURE_ENABLED.store(!want_minimal, Ordering::Release); set_panic_hook(mode); signal_handler::install(mode); let drain_timeout = if crate::terminal::terminal_context().vte_version.is_some() { std::time::Duration::from_millis(20) } else { std::time::Duration::ZERO }; drain_pending_events_with_timeout(drain_timeout); crate::theme::apply_cursor_color(); let ctx = crate::terminal::terminal_context(); let skip_reason: Option<&str> = ctx.kitty_skip_reason() .or_else(|| match terminal::supports_keyboard_enhancement() { Ok(true) => None, _ => Some("unsupported"), }); let use_keyboard_enhancement = skip_reason.is_none(); if use_keyboard_enhancement { let flags = event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES | event::KeyboardEnhancementFlags::REPORT_EVENT_TYPES; kigi_shell::util::with_locked_stderr(|stderr| { let _ = execute!(stderr, event::PushKeyboardEnhancementFlags(flags)); }); tracing::info!( kitty.flags = ? flags, kitty.disambiguate = true, kitty .report_event_types = true, kitty.report_all_keys = false, "kitty keyboard protocol pushed" ); } else { tracing::info!( kitty.flags = "none", kitty.skipped_reason = skip_reason.unwrap_or("unknown"), "kitty keyboard protocol skipped" ); } crate::terminal::set_kitty_flags_pushed(use_keyboard_enhancement); if mode.is_fullscreen() { let backend = CrosstermBackend::new(crate::render::draw::TermWriter::new(frame_tx, writer_sync)); Ok(( kigi_ratatui_inline::Terminal::new(backend)?, ScreenMode::Fullscreen, )) } else { let (cols, rows) = crossterm::terminal::size()?; let viewport_rows = if want_minimal { minimal_live_rows.clamp(3, rows.saturating_sub(1).max(3)) } else { rows }; let probe_backend = CrosstermBackend::new(crate::render::draw::TermWriter::new( frame_tx.clone(), writer_sync.clone(), )); if let Ok(term) = kigi_ratatui_inline::Terminal::with_options( probe_backend, ratatui::TerminalOptions { viewport: ratatui::Viewport::Inline(viewport_rows), }, ) { return Ok(( term, if want_minimal { ScreenMode::Minimal } else { ScreenMode::Inline }, )); } if want_minimal { tracing::warn!( "minimal: inline viewport probe failed; downgrading to full-height inline" ); kigi_shell::util::with_locked_stderr(|stderr| { execute!(stderr, event::EnableMouseCapture) })?; MOUSE_CAPTURE_ENABLED.store(true, Ordering::Release); let retry_backend = CrosstermBackend::new(crate::render::draw::TermWriter::new( frame_tx.clone(), writer_sync.clone(), )); if let Ok(term) = kigi_ratatui_inline::Terminal::with_options( retry_backend, ratatui::TerminalOptions { viewport: ratatui::Viewport::Inline(rows), }, ) { return Ok((term, ScreenMode::Inline)); } } else { tracing::error!("inline viewport probe failed, using Viewport::Fixed"); } kigi_shell::util::with_locked_stderr(|stderr| { execute!( stderr, crossterm::terminal::ScrollUp(rows), cursor::MoveTo(0, 0), ) })?; let backend = CrosstermBackend::new(crate::render::draw::TermWriter::new(frame_tx, writer_sync)); let term = kigi_ratatui_inline::Terminal::with_options( backend, ratatui::TerminalOptions { viewport: ratatui::Viewport::Fixed(ratatui::layout::Rect::new( 0, 0, cols, rows, )), }, )?; Ok((term, ScreenMode::Inline)) } })() .inspect_err(|_| { emit_terminal_teardown_sequences(mode, None); let _ = terminal::disable_raw_mode(); signal_handler::mark_restored(); kigi_crash_handler::disable_terminal_escape_restore(); }) } /// Drop the terminal (closing the writer mpsc channel) and join the /// writer thread. After this returns, subsequent direct stderr writes /// are guaranteed to land strictly after every queued frame. fn drain_writer_thread_before_teardown( terminal: PagerTerminal, writer_thread: crate::render::draw::WriterThread, ) { drop(terminal); writer_thread.join(); } /// Inline teardown escape sequences in the canonical order, shared by /// `restore_terminal` and `set_panic_hook` so the on-wire byte order is /// defined exactly once. /// /// Order: EndSynchronizedUpdate -> reset_cursor_color -> /// disable_mouse_paste_raw -> DisableFocusChange -> pop kitty (if pushed) /// -> mode-specific final block. EndSynchronizedUpdate is emitted first so multiplexers /// (zellij/tmux) stop buffering before the resets arrive. Does NOT call /// `disable_raw_mode`. Callers should drain queued writer-thread frames /// first when possible; the panic hook can't (would deadlock). fn emit_terminal_teardown_sequences(mode: ScreenMode, inline_cursor_row: Option<u16>) { kigi_shell::util::with_locked_stderr(|stderr| { let _ = stderr.write_all(crate::notifications::progress::OSC_CLEAR.as_bytes()); let _ = stderr.flush(); }); kigi_shell::util::with_locked_stderr(|stderr| { let _ = execute!(stderr, crossterm::terminal::EndSynchronizedUpdate); }); crate::theme::reset_cursor_color(); if MOUSE_CAPTURE_ENABLED.swap(false, Ordering::AcqRel) { disable_mouse_paste_raw(); #[cfg(windows)] kigi_shell::util::with_locked_stderr(|stderr| { let _ = execute!(stderr, event::DisableMouseCapture); }); } kigi_shell::util::with_locked_stderr(|stderr| { let _ = execute!(stderr, event::DisableFocusChange); }); pop_gboom_keyboard_flags(); if crate::terminal::take_kitty_flags_pushed() { kigi_shell::util::with_locked_stderr(|stderr| { let _ = execute!(stderr, event::PopKeyboardEnhancementFlags); }); } let restore_style = CURSOR_STYLE_FORCED.load(Ordering::Acquire); if mode.is_fullscreen() { kigi_shell::util::with_locked_stderr(|stderr| { if restore_style { let _ = execute!(stderr, SetCursorStyle::DefaultUserShape); } let _ = execute!(stderr, cursor::Show, LeaveAlternateScreen); }); } else { let rows = crossterm::terminal::size().map(|(_, r)| r).unwrap_or(24); let last = rows.saturating_sub(1); let target = inline_cursor_row.unwrap_or(last).min(last); kigi_shell::util::with_locked_stderr(|stderr| { if restore_style { let _ = execute!(stderr, SetCursorStyle::DefaultUserShape); } let _ = execute!(stderr, cursor::MoveTo(0, target), cursor::Show); let _ = writeln!(stderr); let _ = stderr.flush(); }); } #[cfg(windows)] win_native_selection::restore_stdin_mode(); } /// Consumes `terminal` and `writer_thread`: queues a final clear in /// fullscreen mode, drains the writer thread, then emits the inline /// teardown sequences. The drain ordering guarantees no late frame can /// land after `LeaveAlternateScreen`. fn restore_terminal( mut terminal: PagerTerminal, writer_thread: crate::render::draw::WriterThread, mode: ScreenMode, ) -> io::Result<()> { if mode.is_fullscreen() { let _ = terminal.clear(); { use std::io::Write; let _ = terminal.backend_mut().flush(); } } let inline_cursor_row = (!mode.is_fullscreen()).then(|| terminal.viewport_area().bottom()); drain_writer_thread_before_teardown(terminal, writer_thread); emit_terminal_teardown_sequences(mode, inline_cursor_row); drain_pending_events_with_timeout(std::time::Duration::from_millis(10)); let _ = terminal::disable_raw_mode(); signal_handler::mark_restored(); kigi_crash_handler::disable_terminal_escape_restore(); kigi_tty_utils::restore_native_stderr(); Ok(()) } pub(crate) fn set_terminal_title(title: &str) { let full = terminal_title_string(title); kigi_shell::util::with_locked_stderr(|stderr| { let _ = execute!(stderr, SetTitle(full)); }); } /// Sanitized/truncated window title. Strips control characters: crossterm's /// `SetTitle` emits the string raw inside an OSC sequence, so an embedded /// BEL/ESC (titles can arrive from kimi.com conversation metadata) would /// terminate the OSC early and let the remainder inject arbitrary escape /// sequences into the terminal. fn terminal_title_string(title: &str) -> String { let sanitized: String = title.chars().filter(|c| !c.is_control()).collect(); if sanitized.is_empty() { "kigi".into() } else { let truncated: String = sanitized.chars().take(80 - 6).collect(); format!("{} - kigi", truncated) } } fn set_panic_hook(mode: ScreenMode) { let hook = panic::take_hook(); panic::set_hook(Box::new(move |info| { emit_terminal_teardown_sequences(mode, None); let _ = terminal::disable_raw_mode(); signal_handler::mark_restored(); kigi_crash_handler::disable_terminal_escape_restore(); kigi_tty_utils::restore_native_stderr(); kigi_tty_utils::global_process_scope().kill_all(); hook(info); })); } #[cfg(test)] mod tests { use super::*; /// `[ui].cursor_blink` tri-state → startup cursor policy; the `None` /// default must be Inherit (emit nothing). #[test] fn cursor_blink_config_maps_to_policy() { assert_eq!(cursor_style_policy(None), CursorStylePolicy::Inherit); assert_eq!( cursor_style_policy(Some(true)), CursorStylePolicy::ForceBlinking ); assert_eq!( cursor_style_policy(Some(false)), CursorStylePolicy::ForceSteady ); } fn empty_config() -> toml::Value { toml::Value::Table(Default::default()) } fn config_with_leader(enabled: bool) -> toml::Value { let toml_str = format!("[cli]\nuse_leader = {enabled}"); toml::from_str(&toml_str).unwrap() } #[test] fn terminal_title_strips_control_characters() { assert_eq!( terminal_title_string("evil\x07\x1b]52;c;payload\x07title"), "evil]52;c;payloadtitle - kigi" ); assert_eq!(terminal_title_string("\x07\x1b\x00"), "kigi"); assert_eq!(terminal_title_string(""), "kigi"); assert_eq!(terminal_title_string("My chat"), "My chat - kigi"); } #[test] fn hunk_tracker_mode_nothing_set_is_none() { assert_eq!(resolve_hunk_tracker_mode(None, None, None), None); } #[test] fn hunk_tracker_mode_empty_env_is_none() { assert_eq!(resolve_hunk_tracker_mode(None, Some(""), None), None); assert_eq!(resolve_hunk_tracker_mode(None, Some(" "), None), None); assert_eq!(resolve_hunk_tracker_mode(None, None, Some("")), None); } #[test] fn hunk_tracker_mode_precedence_cli_over_env_over_config() { assert_eq!( resolve_hunk_tracker_mode(Some("off"), Some("all_dirty"), Some("agent_only")), Some("off".to_string()), ); assert_eq!( resolve_hunk_tracker_mode(Some(""), Some("all_dirty"), Some("agent_only")), Some("all_dirty".to_string()), ); assert_eq!( resolve_hunk_tracker_mode(Some(" "), Some(""), Some("agent_only")), Some("agent_only".to_string()), ); } #[test] fn hunk_tracker_mode_trims_and_passes_off_through() { assert_eq!( resolve_hunk_tracker_mode(Some(" off "), None, None), Some("off".to_string()), ); assert_eq!( resolve_hunk_tracker_mode(None, Some("disabled"), None), Some("disabled".to_string()), ); } #[test] fn no_leader_flag_wins_over_leader_flag_and_config() { let cfg = config_with_leader(true); let (use_leader, reason) = resolve_use_leader(true, true, &cfg, true); assert!(!use_leader); assert_eq!(reason, None); } #[test] fn leader_flag_enables() { let (use_leader, reason) = resolve_use_leader(true, false, &empty_config(), true); assert!(use_leader); assert_eq!(reason, None); } #[test] fn not_eligible_returns_false() { let cfg = config_with_leader(true); let (use_leader, reason) = resolve_use_leader(false, false, &cfg, false); assert!(!use_leader); assert_eq!(reason, None); } #[test] fn config_toml_enables() { let cfg = config_with_leader(true); let (use_leader, reason) = resolve_use_leader(false, false, &cfg, true); assert!(use_leader); assert_eq!(reason, None); } #[test] fn config_toml_disables() { let cfg = config_with_leader(false); let (use_leader, reason) = resolve_use_leader(false, false, &cfg, true); assert!(!use_leader); assert_eq!(reason, Some("config")); } #[test] fn default_is_false() { let (use_leader, reason) = resolve_use_leader(false, false, &empty_config(), true); assert!(!use_leader); assert_eq!(reason, None); } #[test] fn cli_flag_overrides_config() { let cfg = config_with_leader(false); let (use_leader, reason) = resolve_use_leader(true, false, &cfg, true); assert!(use_leader); assert_eq!(reason, None); } fn try_parse_pager(args: &[&str]) -> Result<PagerArgs, clap::Error> { use clap::Parser; PagerArgs::try_parse_from(args) } #[test] fn cli_leader_and_no_leader_conflict() { let result = try_parse_pager(&["kigi-pager", "--leader", "--no-leader"]); assert!(result.is_err()); } #[test] fn cli_leader_flag_parses() { let args = try_parse_pager(&["kigi-pager", "--leader"]).unwrap(); assert!(args.leader); assert!(!args.no_leader); } #[test] fn cli_no_leader_flag_parses() { let args = try_parse_pager(&["kigi-pager", "--no-leader"]).unwrap(); assert!(!args.leader); assert!(args.no_leader); } #[test] fn cli_neither_leader_flag_defaults_false() { let args = try_parse_pager(&["kigi-pager"]).unwrap(); assert!(!args.leader); assert!(!args.no_leader); } #[test] fn no_leader_flag_overrides_config_for_tui_fallback() { let cfg = config_with_leader(true); let (use_leader, reason) = resolve_use_leader(false, true, &cfg, true); assert!(!use_leader); assert_eq!(reason, None); } /// clap accepts top-level --leader with agent subcommand, so /// main() must reject the combination at runtime. #[test] fn cli_top_level_leader_with_agent_subcommand_parses_flag() { let args = try_parse_pager(&["kigi-pager", "--leader", "agent", "stdio"]).unwrap(); assert!(args.leader); assert!(matches!(args.command, Some(Command::Agent(_)))); } #[test] fn cli_top_level_no_leader_with_agent_subcommand_parses_flag() { let args = try_parse_pager(&["kigi-pager", "--no-leader", "agent", "stdio"]).unwrap(); assert!(args.no_leader); assert!(matches!(args.command, Some(Command::Agent(_)))); } /// Bare `agent` parses with no mode and defaults to stdio at dispatch /// (kimi-cli parity, F6 — the `acp` alias relies on the same default). #[test] fn cli_bare_agent_subcommand_defaults_to_stdio() { let args = try_parse_pager(&["kigi-pager", "agent"]).unwrap(); let Some(Command::Agent(agent)) = args.command else { panic!("expected agent subcommand"); }; assert!(agent.mode.is_none(), "bare agent carries no explicit mode"); } #[test] fn leader_defaults_off_without_config() { let (use_leader, reason) = resolve_use_leader(false, false, &empty_config(), true); assert!(!use_leader); assert_eq!(reason, None); } #[test] fn cli_resume_parses_session_id() { let args = try_parse_pager(&["kigi-pager", "--resume", "abc-123"]).unwrap(); assert_eq!(args.session_to_resume(), Some("abc-123")); } #[test] fn cli_short_r_parses_session_id() { let args = try_parse_pager(&["kigi-pager", "-r", "abc-123"]).unwrap(); assert_eq!(args.session_to_resume(), Some("abc-123")); } #[test] fn cli_load_alias_parses_session_id() { let args = try_parse_pager(&["kigi-pager", "--load", "abc-123"]).unwrap(); assert_eq!(args.session_to_resume(), Some("abc-123")); } #[test] fn cli_resume_preferred_over_load() { let mut args = try_parse_pager(&["kigi-pager", "--resume", "from-resume"]).unwrap(); args.load_session = Some("from-load".into()); assert_eq!(args.session_to_resume(), Some("from-resume")); } #[test] fn cli_continue_flag_parses() { let args = try_parse_pager(&["kigi-pager", "--continue"]).unwrap(); assert!(args.continue_last_session); assert_eq!(args.session_to_resume(), None); } #[test] fn cli_continue_short_c_parses() { let args = try_parse_pager(&["kigi-pager", "-c"]).unwrap(); assert!(args.continue_last_session); } #[test] fn cli_resume_no_id_sets_empty_sentinel() { let args = try_parse_pager(&["kigi-pager", "--resume"]).unwrap(); assert_eq!(args.resume_session.as_deref(), Some("")); assert!(args.resume_most_recent()); assert_eq!(args.session_to_resume(), None); } #[test] fn cli_short_r_no_id_sets_empty_sentinel() { let args = try_parse_pager(&["kigi-pager", "-r"]).unwrap(); assert_eq!(args.resume_session.as_deref(), Some("")); assert!(args.resume_most_recent()); } #[test] fn cli_resume_with_id_is_not_most_recent() { let args = try_parse_pager(&["kigi-pager", "--resume", "abc-123"]).unwrap(); assert!(!args.resume_most_recent()); assert_eq!(args.session_to_resume(), Some("abc-123")); } #[test] fn cli_no_resume_is_not_most_recent() { let args = try_parse_pager(&["kigi-pager"]).unwrap(); assert!(!args.resume_most_recent()); } #[test] fn cli_continue_conflicts_with_resume() { let result = try_parse_pager(&["kigi-pager", "--continue", "--resume", "abc"]); assert!(result.is_err()); } #[test] fn cli_continue_conflicts_with_load() { let result = try_parse_pager(&["kigi-pager", "--continue", "--load", "abc"]); assert!(result.is_err()); } #[test] fn cli_no_session_flags_defaults() { let args = try_parse_pager(&["kigi-pager"]).unwrap(); assert!(!args.continue_last_session); assert!(args.worktree.is_none()); assert_eq!(args.session_to_resume(), None); assert!(!args.chat()); } /// Without the optional feature the flag must not exist at all: a stable /// binary given that flag fails clap parsing instead of silently ignoring. #[test] fn cli_chat_flag_rejected_without_feature() { assert!(try_parse_pager(&["kigi-pager", "--chat"]).is_err()); } #[test] fn chat_mode_leader_guard_truth_table() { assert!(session_startup::chat_mode_conflicts_with_leader(true, true)); assert!(!session_startup::chat_mode_conflicts_with_leader( true, false )); assert!(!session_startup::chat_mode_conflicts_with_leader( false, true )); assert!(!session_startup::chat_mode_conflicts_with_leader( false, false )); } #[test] fn cli_worktree_flag_parses() { let args = try_parse_pager(&["kigi-pager", "--worktree"]).unwrap(); assert_eq!(args.worktree.as_deref(), Some("")); } #[test] fn cli_worktree_short_w_parses() { let args = try_parse_pager(&["kigi-pager", "-w"]).unwrap(); assert_eq!(args.worktree.as_deref(), Some("")); } #[test] fn cli_worktree_with_label() { let args = try_parse_pager(&["kigi-pager", "-w", "my-label"]).unwrap(); assert_eq!(args.worktree.as_deref(), Some("my-label")); } #[test] fn cli_worktree_long_with_label() { let args = try_parse_pager(&["kigi-pager", "--worktree", "fix-bug"]).unwrap(); assert_eq!(args.worktree.as_deref(), Some("fix-bug")); } #[test] fn cli_worktree_with_empty_string() { let args = try_parse_pager(&["kigi-pager", "-w", ""]).unwrap(); assert_eq!(args.worktree.as_deref(), Some("")); } #[test] fn cli_worktree_with_resume_parses() { let args = try_parse_pager(&["kigi-pager", "-w", "--resume", "abc"]).unwrap(); assert_eq!(args.worktree.as_deref(), Some("")); assert_eq!(args.session_to_resume(), Some("abc")); } #[test] fn cli_worktree_label_with_resume() { let args = try_parse_pager(&["kigi-pager", "-w", "my-label", "--resume", "abc"]).unwrap(); assert_eq!(args.worktree.as_deref(), Some("my-label")); assert_eq!(args.session_to_resume(), Some("abc")); } #[test] fn cli_worktree_default_none() { let args = try_parse_pager(&["kigi-pager"]).unwrap(); assert!(args.worktree.is_none()); } #[test] fn cli_session_id_parses() { let args = try_parse_pager(&["kigi-pager", "--session-id", "my-id"]).unwrap(); assert_eq!(args.session_id.as_deref(), Some("my-id")); assert!(matches!( args.session_startup_intent().unwrap(), crate::app::session_startup::SessionStartupIntent::NewWithId { .. } )); } #[test] fn cli_session_id_short_s_parses() { let args = try_parse_pager(&["kigi-pager", "-s", "my-id"]).unwrap(); assert_eq!(args.session_id.as_deref(), Some("my-id")); } #[test] fn cli_session_id_with_resume_requires_fork() { let args = try_parse_pager(&["kigi-pager", "-s", "a", "--resume", "b"]).unwrap(); assert!(args.session_startup_intent().is_err()); } #[test] fn cli_session_id_with_continue_requires_fork() { let args = try_parse_pager(&["kigi-pager", "-s", "a", "--continue"]).unwrap(); assert!(args.session_startup_intent().is_err()); } #[test] fn cli_session_id_with_resume_and_fork_ok() { let args = try_parse_pager(&["kigi-pager", "-s", "a", "--resume", "b", "--fork-session"]).unwrap(); assert!(args.session_startup_intent().is_ok()); } #[test] fn cli_session_id_default_none() { let args = try_parse_pager(&["kigi-pager"]).unwrap(); assert!(args.session_id.is_none()); } #[test] fn cli_no_alt_screen_flag_parses() { let args = try_parse_pager(&["kigi-pager", "--no-alt-screen"]).unwrap(); assert!(args.no_alt_screen); } #[test] fn cli_no_alt_screen_default_false() { let args = try_parse_pager(&["kigi-pager"]).unwrap(); assert!(!args.no_alt_screen); } #[test] fn cli_command_name_is_kigi() { use clap::CommandFactory; assert_eq!(PagerArgs::command().get_name(), "kigi"); } #[test] fn cli_help_output_header() { use clap::CommandFactory; let help = PagerArgs::command().render_long_help().to_string(); let first_5: Vec<&str> = help.lines().take(5).collect(); assert_eq!( first_5, vec![ "Kigi — unofficial Kimi Code CLI community build", "", "Usage: kigi [OPTIONS] [PROMPT] [COMMAND]", "", "Arguments:", ] ); assert!(help.find("Arguments:\n").unwrap() < help.find("Options:\n").unwrap()); assert!(help.find("Options:\n").unwrap() < help.find("Commands:\n").unwrap()); } #[test] fn cli_completions_parses() { use clap_complete::Shell; let args = try_parse_pager(&["kigi-pager", "completions", "zsh"]).unwrap(); assert!(matches!( args.command, Some(Command::Completions { shell: Shell::Zsh }) )); let args = try_parse_pager(&["kigi-pager", "completions", "bash"]).unwrap(); assert!(matches!( args.command, Some(Command::Completions { shell: Shell::Bash }) )); } /// Always fails writes with EIO (os error 5) — closed-pane stderr. struct AlwaysFailWrite; impl Write for AlwaysFailWrite { fn write(&mut self, _buf: &[u8]) -> io::Result<usize> { Err(io::Error::from_raw_os_error(5)) } fn flush(&mut self) -> io::Result<()> { Err(io::Error::from_raw_os_error(5)) } } #[test] fn print_exit_resume_hint_writes_expected_lines() { let mut buf = Vec::new(); print_exit_resume_hint("sess-abc", false, &mut buf); assert_eq!( String::from_utf8(buf).unwrap(), "\nResume this session with:\n kigi --resume sess-abc\n" ); } #[test] fn print_exit_resume_hint_includes_minimal_flag() { let mut buf = Vec::new(); print_exit_resume_hint("sess-abc", true, &mut buf); assert_eq!( String::from_utf8(buf).unwrap(), "\nResume this session with:\n kigi --minimal --resume sess-abc\n" ); } #[test] fn print_relaunch_failure_hint_writes_expected_lines() { let mut buf = Vec::new(); print_relaunch_failure_hint(&"exec failed", "sess-xyz", false, &mut buf); let hint = screen_mode_relaunch::screen_mode_relaunch_resume_hint("sess-xyz", false); assert_eq!( String::from_utf8(buf).unwrap(), format!( "Failed to relaunch in requested mode: exec failed\n\ Resume this session with:\n {hint}\n" ) ); } #[test] fn print_hints_survive_eio() { let mut w = AlwaysFailWrite; print_exit_resume_hint("sess-abc", false, &mut w); print_exit_resume_hint("sess-abc", true, &mut w); print_relaunch_failure_hint(&"exec failed", "sess-xyz", true, &mut w); } /// Close the *read* end so writes on the write end get EPIPE /// (SIGPIPE is SIG_IGN → BrokenPipe, not process death). #[cfg(unix)] #[test] fn print_hints_survive_closed_pipe() { use std::os::unix::io::FromRawFd; let mut fds = [0i32; 2]; let rc = unsafe { libc::pipe(fds.as_mut_ptr()) }; assert_eq!(rc, 0, "pipe() failed"); unsafe { libc::close(fds[0]); } let mut writer = unsafe { std::fs::File::from_raw_fd(fds[1]) }; print_exit_resume_hint("pipe-sid", false, &mut writer); print_exit_resume_hint("pipe-sid", true, &mut writer); print_relaunch_failure_hint(&"exec failed", "pipe-sid", false, &mut writer); } }