Files
Kigi-CLI/crates/codegen/kigi-tui/src/app/event_loop.rs
T
ZacharyZhang-NY 10d0c0816f Excise the Changelog feature
Kigi never publishes CDN changelogs, so the entire inherited feature
was dead weight: the welcome-menu Changelog row, the hero-box info
slot (bullets + clickable CTA), /release-notes with its /changelog
alias, and the ChangelogManager CDN-fetch/disk-cache pipeline
(Effect::FetchChangelog, TaskResult::ChangelogFetched, startup and
post-login fetch kickoffs, AppView cache fields, mouse hover/click
handling). Welcome menu is now [Import] / New worktree / Resume
session / Quit; the hero box keeps title + version + subtitle and its
layout math simplifies to 3 + menu rows (verified equivalent by the
surviving boundary tests). Action::ShowReleaseNotes and the DocViewer
modal stay — /docs uses them. builtin.rs keeps deleting stale
CHANGELOG.{json,md} caches written by kigi ≤ 0.1.0.

kigi-shell-base drops its reqwest 'blocking' feature (only the deleted
module used it). -1206 lines net.

Gates: fmt clean, workspace check/clippy --all-targets 0/0, kigi-tui
lib 6609 / shell-base 56 / shell util:: 258 all passing, welcome pty
e2e (3 tests incl. braille logo) passing.
2026-07-18 12:31:52 -04:00

3667 lines
160 KiB
Rust

//! 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<super::ExitInfo>,
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<super::app_view::ScreenModeRelaunch>,
}
/// 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<ReinitOutcome>,
/// Agents being reloaded, active tab first; empty when the reconnect
/// happened with no open sessions (init/auth are still re-run).
agent_ids: Vec<super::agent::AgentId>,
/// 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<AgentLoadOutcome>,
}
/// 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<String>,
}
/// 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<ReconnectLoadPlan> {
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<super::agent::AgentId, (bool, Option<String>)>,
active_agent_id: Option<super::agent::AgentId>,
) -> (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<crossterm::event::Event>,
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<crossterm::event::Event>,
last_draw_at: &mut Instant,
draw_scheduled_at: &mut Option<Instant>,
) {
// $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<String> = 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<std::path::PathBuf>,
remote_settings: Option<kigi_shell::util::config::RemoteSettings>,
term_state: TerminalState,
materialized: crate::app::session_startup::MaterializedStartup,
bg_update_rx: Option<
tokio::sync::oneshot::Receiver<Option<kigi_update::auto_update::UpdateAvailable>>,
>,
) -> anyhow::Result<RunResult> {
// 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::<kigi_shell::auth::AuthMeta>(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::<Event>();
// 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<TaskResult> = JoinSet::new();
let (progress_tx, mut progress_rx) =
tokio::sync::mpsc::unbounded_channel::<effects::RestoreProgressMsg>();
// Animation tick: only scheduled when there are running entries.
let mut tick_interval = tick_interval;
let mut animation_tick_at: Option<Instant> = 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<Instant> = 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<Instant> = 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<Instant> = 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<Instant> = None;
let mut reconnect_reinit: Option<ReconnectReinit> = None;
let mut reconnect_abort_handle: Option<tokio::task::AbortHandle> = 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::<Result<(), _>>().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::<Result<(), ()>>().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::<Vec<_>>(),
})),
);
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<super::agent::AgentId> =
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::<Result<ReinitOutcome, _>>().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::<UiConfig>().unwrap_or_default()
}
/// Config `Option<bool>` 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<bool>,
auto_update: Option<bool>,
ask_user_question_timeout_enabled: Option<bool>,
}
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<bool> { 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<Instant>, 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<SystemAppearanceWatcher>) {
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<Event>,
app: &mut AppView,
tasks: &mut JoinSet<TaskResult>,
progress_tx: &tokio::sync::mpsc::UnboundedSender<effects::RestoreProgressMsg>,
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::<Vec<_>>();
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<Event>,
input_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Event>,
) -> 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<Event>,
input_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Event>,
) {
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<Event>,
input_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Event>,
) {
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<Event>) -> Vec<Event> {
// 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<Event> = 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<Event>) -> Vec<Event> {
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<super::actions::Effect>,
tasks: &mut JoinSet<TaskResult>,
app: &mut AppView,
progress_tx: &tokio::sync::mpsc::UnboundedSender<effects::RestoreProgressMsg>,
) -> 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<Event> {
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()));
}
}