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