M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
ZacharyZhang-NY committed 2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 files changed
+1353757

No files matched your search

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,54 @@
//! Hot-reloadable appearance configuration.
//!
//! Two unrelated concerns live under this module name:
//!
//! - **`config` + `watcher`**: dev-only `~/.kigi/pager.toml` RenderConfig
//! (200+ fields for terminal rendering tuning). Hot-reloaded in dev mode,
//! static defaults in prod.
//! - **`cache`**: thread-local in-memory caches for the user-facing UI bool
//! settings (`compact_mode`, `show_timestamps`, `simple_mode`). Disk
//! writes happen in `kigi_shell::util::config::set_<field>()` via
//! `Effect::PersistSetting`, NOT here — this is a read-cache only.
//! - **`permission_cursor`**: the `default_selected_permission` value type
//! plus the caches and resolution logic for which row a permission prompt
//! preselects.
pub mod cache;
mod config;
pub mod permission_cursor;
pub mod render_mermaid;
pub mod scroll_mode;
pub mod text_selection;
mod watcher;
pub use config::{
AnimationConfig, AppearanceConfig, BlockBackground, BlocksConfig, EditBlockConfig,
ExecuteHeaderStyle, FollowIndicator, LayoutConfig, PromptConfig, PromptViewConfig,
RawAltScreenMode, RawAppearanceConfig, RawTerminalConfig, ScrollConfig, ScrollbackConfig,
ScrollbarConfig, TodoBadgeFormat, TodoConfig, ToolBullet, ToolConfig,
persist_respect_manual_folds,
};
pub use render_mermaid::RenderMermaid;
pub use scroll_mode::ScrollMode;
pub use text_selection::TextSelection;
pub use watcher::ConfigWatcher;
// -- Global tab_width --------------------------------------------------------
//
// Stored as an atomic so MarkdownContent can read the current value
// without needing the AppearanceConfig threaded through its API.
// Updated by the event loop whenever pager.toml is (re)loaded.
use std::sync::atomic::{AtomicU8, Ordering};
static TAB_WIDTH: AtomicU8 = AtomicU8::new(4);
/// Current tab expansion width (number of spaces per `\t`).
pub fn tab_width() -> u8 {
TAB_WIDTH.load(Ordering::Relaxed)
}
/// Update the global tab width (called when config is loaded/reloaded).
pub fn set_tab_width(w: u8) {
TAB_WIDTH.store(w, Ordering::Relaxed);
}
@@ -0,0 +1,455 @@
//! Permission-prompt cursor preselection.
//!
//! Single home for everything that decides which row the approval menu
//! highlights when the agent asks for permission:
//!
//! - [`DefaultSelectedPermission`] — the value type (config + ACP-kind bridge),
//! - the process-wide caches (configured value + sticky last-used),
//! - [`resolve_initial_cursor`] — the one function the ACP handler calls when
//! queueing a prompt.
//!
//! Deliberately kept out of `views::permission_view` (a renderer) and out of
//! `appearance::cache` (generic bool/u8 setting caches) so this cross-cutting
//! type and its state live together, and the render-hot-path cache module
//! doesn't have to depend upward on the view layer.
use std::cell::Cell;
use agent_client_protocol as acp;
use kigi_workspace::permission::is_enable_always_approve_option;
/// Which row the approval-menu cursor preselects (the highlighted row).
///
/// Persisted as `[ui].default_selected_permission`. The four variants map onto
/// the rows a permission prompt can show:
///
/// - [`AlwaysAllowAllSessions`](Self::AlwaysAllowAllSessions) — the global
/// "enable always-approve" row ("Always allow on all sessions"). This is
/// also the value used when the setting is unset or unrecognised, so it is
/// the effective default.
/// - [`AllowOnce`](Self::AllowOnce) — the plain "Yes" / allow-once row.
/// - [`AllowCommandAlways`](Self::AllowCommandAlways) — the prompt-scoped
/// always-allow row ("Always allow this command" — also covers the
/// per-tool / per-domain / per-edit-session variants of the same ACP kind).
/// - [`Reject`](Self::Reject) — the reject row.
///
/// The configured value only steers the **first** prompt of a session; after
/// the user confirms a prompt, [`resolve_initial_cursor`] sticks to the
/// last-used kind (recorded via [`set_last_used_permission`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DefaultSelectedPermission {
/// The global "Always allow on all sessions" (enable-always-approve) row.
/// Also the fallback for an unset / unrecognised config value, so it is the
/// effective default.
AlwaysAllowAllSessions,
AllowOnce,
/// The prompt-scoped always-allow row ("Always allow this command" /
/// tool / domain / edit-session).
AllowCommandAlways,
Reject,
}
impl DefaultSelectedPermission {
/// The single canonical config.toml / registry string for this variant.
/// One accepted value per variant — there are no aliases. `const` so the
/// settings catalog can build its choice table at compile time.
pub const fn as_canonical(self) -> &'static str {
match self {
Self::AlwaysAllowAllSessions => "always_allow_all_sessions",
Self::AllowOnce => "allow_once",
Self::AllowCommandAlways => "allow_command_always",
Self::Reject => "reject",
}
}
/// Display label for the settings picker and the change toast.
/// `AllowCommandAlways` preselects the prompt-specific always-allow row
/// (per-command / per-tool / per-domain / per-edit-session), never a
/// global allow-everything — that is `AlwaysAllowAllSessions`.
pub const fn display(self) -> &'static str {
match self {
Self::AlwaysAllowAllSessions => "Always allow on all sessions",
Self::AllowOnce => "Allow once",
Self::AllowCommandAlways => "Always allow this command",
Self::Reject => "Reject",
}
}
/// Parse a config.toml / registry value (trimmed, case-insensitive, no
/// aliases). The mapping is total — `always_allow_all_sessions` and any
/// unrecognised / empty value both resolve to
/// [`AlwaysAllowAllSessions`](Self::AlwaysAllowAllSessions), so no `Option`
/// has to be threaded through callers.
pub fn from_config_value(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"allow_once" => Self::AllowOnce,
"allow_command_always" => Self::AllowCommandAlways,
"reject" => Self::Reject,
_ => Self::AlwaysAllowAllSessions,
}
}
/// Whether this preselection targets the given ACP option kind.
///
/// [`AlwaysAllowAllSessions`](Self::AlwaysAllowAllSessions) targets no
/// specific kind — its row is the enable-always-approve option, matched by
/// identity in [`resolve_initial_cursor`], so the cursor falls there when
/// nothing else matches. [`Reject`](Self::Reject) matches **both** reject
/// kinds so a sticky reject lands on whichever reject row a given prompt
/// offers (some prompts carry only `RejectAlways`).
pub fn matches_kind(self, kind: &acp::PermissionOptionKind) -> bool {
matches!(
(self, kind),
(Self::AllowOnce, acp::PermissionOptionKind::AllowOnce)
| (
Self::AllowCommandAlways,
acp::PermissionOptionKind::AllowAlways
)
| (
Self::Reject,
acp::PermissionOptionKind::RejectOnce | acp::PermissionOptionKind::RejectAlways
)
)
}
/// Map a confirmed ACP option kind onto the sticky "last used" target.
/// Both reject kinds collapse to [`Reject`](Self::Reject). Total over every
/// ACP kind, so it never yields
/// [`AlwaysAllowAllSessions`](Self::AlwaysAllowAllSessions) — that row is
/// the enable-always-approve option, which callers exclude from sticky
/// recording.
pub fn from_kind(kind: &acp::PermissionOptionKind) -> Self {
match kind {
acp::PermissionOptionKind::AllowOnce => Self::AllowOnce,
acp::PermissionOptionKind::AllowAlways => Self::AllowCommandAlways,
acp::PermissionOptionKind::RejectOnce | acp::PermissionOptionKind::RejectAlways => {
Self::Reject
}
// TODO(acp-0.10): `PermissionOptionKind` is #[non_exhaustive];
// treat unknown kinds as reject (never auto-allow).
_ => Self::Reject,
}
}
}
// ── Configured value cache: `[ui].default_selected_permission` ──────────────
//
// Read when queueing the first prompt of a session. Seeded by `prime` at
// startup (and lazily on first read) so the path never hits disk mid-session.
// `AlwaysAllowAllSessions` represents the effective default (unset).
thread_local! {
static CONFIG_CURRENT: Cell<DefaultSelectedPermission> =
const { Cell::new(DefaultSelectedPermission::AlwaysAllowAllSessions) };
static CONFIG_LOADED: Cell<bool> = const { Cell::new(false) };
}
/// Read the cached `[ui].default_selected_permission`, seeding on first call.
///
/// Precedence (mirrors `appearance::cache::load_scroll_speed`):
///
/// 1. `KIGI_DEFAULT_SELECTED_PERMISSION` env var (headless / agent testing —
/// overrides `config.toml` without editing it),
/// 2. `[ui].default_selected_permission` in the layered effective config,
/// 3. [`AlwaysAllowAllSessions`](DefaultSelectedPermission::AlwaysAllowAllSessions)
/// (the effective default).
///
/// Unrecognised / empty values at any layer fall through to the next.
pub fn load_default_selected_permission() -> DefaultSelectedPermission {
CONFIG_LOADED.with(|loaded| {
if !loaded.get() {
let resolved = std::env::var("KIGI_DEFAULT_SELECTED_PERMISSION")
.ok()
.map(|s| DefaultSelectedPermission::from_config_value(&s))
.filter(|p| *p != DefaultSelectedPermission::AlwaysAllowAllSessions)
.or_else(|| {
load_string_from_effective_config("default_selected_permission")
.map(|s| DefaultSelectedPermission::from_config_value(&s))
})
.unwrap_or(DefaultSelectedPermission::AlwaysAllowAllSessions);
CONFIG_CURRENT.with(|c| c.set(resolved));
loaded.set(true);
}
});
CONFIG_CURRENT.with(Cell::get)
}
/// Replace the cached configured value (optimistic update from the settings
/// modal, or rollback on persist failure). The next prompt sees it without a
/// restart.
pub fn set_default_selected_permission(value: DefaultSelectedPermission) {
CONFIG_CURRENT.with(|c| c.set(value));
CONFIG_LOADED.with(|l| l.set(true));
}
/// Eagerly seed the configured-value cache at startup (called by
/// `appearance::cache::prime`) so the first prompt never hits disk.
pub fn prime() {
let _ = load_default_selected_permission();
}
// ── Sticky "last used" cursor target ────────────────────────────────────────
//
// Process-wide ephemeral state: the kind the user most recently confirmed.
// After the first prompt, `resolve_initial_cursor` prefers this over the
// configured value. `AlwaysAllowAllSessions` is the sentinel meaning nothing
// has been confirmed yet (`from_kind` never produces it). The TUI renders +
// dispatches on a single thread, so a thread-local `Cell` is fine.
thread_local! {
static LAST_USED: Cell<DefaultSelectedPermission> =
const { Cell::new(DefaultSelectedPermission::AlwaysAllowAllSessions) };
}
/// The kind the user last confirmed this session, or the
/// [`AlwaysAllowAllSessions`](DefaultSelectedPermission::AlwaysAllowAllSessions)
/// sentinel if none yet.
pub fn last_used_permission() -> DefaultSelectedPermission {
LAST_USED.with(Cell::get)
}
/// Record the kind the user just confirmed. Callers must skip the special
/// enable-always-approve (YOLO) and allow-edits-session options — neither
/// represents a per-prompt choice that should steer a later prompt's cursor.
pub fn set_last_used_permission(kind: DefaultSelectedPermission) {
LAST_USED.with(|c| c.set(kind));
}
// ── Resolution ──────────────────────────────────────────────────────────────
/// Pick the initially-highlighted row for a freshly-queued permission prompt.
///
/// Precedence:
///
/// 1. the sticky last-used kind (once the user has confirmed any prompt),
/// 2. the configured `[ui].default_selected_permission`,
/// 3. the global "Always allow on all sessions" (enable-always-approve) row,
/// matched by identity via `is_enable_always_approve_option` — not by list
/// position, so the intent lives in the code rather than the option order,
/// 4. index 0 (clients that don't get the YOLO row prepended).
///
/// The YOLO row is skipped while a concrete target kind is in play, so a
/// configured / sticky preselection never lands on it.
pub fn resolve_initial_cursor(options: &[acp::PermissionOption]) -> usize {
let target = match last_used_permission() {
DefaultSelectedPermission::AlwaysAllowAllSessions => load_default_selected_permission(),
sticky => sticky,
};
options
.iter()
.position(|o| target.matches_kind(&o.kind) && !is_enable_always_approve_option(o))
.or_else(|| options.iter().position(is_enable_always_approve_option))
.unwrap_or(0)
}
/// Read a `[ui].<key>` string from the shell's layered effective config.
/// Returns `None` when the key is absent or not a string.
fn load_string_from_effective_config(key: &str) -> Option<String> {
let root = kigi_config::load_effective_config_disk_only().ok()?;
root.get("ui")?
.get(key)?
.as_str()
.map(std::string::ToString::to_string)
}
// -- Tests -------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use kigi_workspace::permission::ENABLE_ALWAYS_APPROVE_OPTION_ID;
fn opt(id: &str, kind: acp::PermissionOptionKind) -> acp::PermissionOption {
acp::PermissionOption::new(acp::PermissionOptionId::new(id), id.to_owned(), kind)
}
#[test]
fn from_config_value_accepts_one_canonical_per_variant() {
// Trimmed + case-insensitive, but still the exact canonical token.
assert_eq!(
DefaultSelectedPermission::from_config_value("allow_once"),
DefaultSelectedPermission::AllowOnce
);
assert_eq!(
DefaultSelectedPermission::from_config_value(" ALLOW_COMMAND_ALWAYS "),
DefaultSelectedPermission::AllowCommandAlways
);
assert_eq!(
DefaultSelectedPermission::from_config_value("reject"),
DefaultSelectedPermission::Reject
);
assert_eq!(
DefaultSelectedPermission::from_config_value("always_allow_all_sessions"),
DefaultSelectedPermission::AlwaysAllowAllSessions
);
// Empty and garbage collapse to the effective default.
assert_eq!(
DefaultSelectedPermission::from_config_value(""),
DefaultSelectedPermission::AlwaysAllowAllSessions
);
assert_eq!(
DefaultSelectedPermission::from_config_value("bogus"),
DefaultSelectedPermission::AlwaysAllowAllSessions
);
}
#[test]
fn canonical_round_trips() {
// `as_canonical` is the inverse of `from_config_value` for every
// variant — the enum is the single source of truth for the strings.
for variant in [
DefaultSelectedPermission::AlwaysAllowAllSessions,
DefaultSelectedPermission::AllowOnce,
DefaultSelectedPermission::AllowCommandAlways,
DefaultSelectedPermission::Reject,
] {
assert_eq!(
DefaultSelectedPermission::from_config_value(variant.as_canonical()),
variant,
);
assert!(!variant.display().is_empty());
}
}
#[test]
fn matches_kind_targets_the_right_rows() {
use DefaultSelectedPermission as P;
assert!(P::AllowOnce.matches_kind(&acp::PermissionOptionKind::AllowOnce));
assert!(P::AllowCommandAlways.matches_kind(&acp::PermissionOptionKind::AllowAlways));
assert!(!P::AllowCommandAlways.matches_kind(&acp::PermissionOptionKind::AllowOnce));
// A sticky reject must land on EITHER reject row, since some prompts
// only offer `RejectAlways`.
assert!(P::Reject.matches_kind(&acp::PermissionOptionKind::RejectOnce));
assert!(P::Reject.matches_kind(&acp::PermissionOptionKind::RejectAlways));
// The always-allow-on-all-sessions sentinel targets no specific kind
// (its row is matched by identity; the cursor falls to it).
for kind in [
acp::PermissionOptionKind::AllowOnce,
acp::PermissionOptionKind::AllowAlways,
acp::PermissionOptionKind::RejectOnce,
acp::PermissionOptionKind::RejectAlways,
] {
assert!(!P::AlwaysAllowAllSessions.matches_kind(&kind));
}
}
#[test]
fn from_kind_is_total_and_collapses_reject() {
assert_eq!(
DefaultSelectedPermission::from_kind(&acp::PermissionOptionKind::AllowOnce),
DefaultSelectedPermission::AllowOnce
);
assert_eq!(
DefaultSelectedPermission::from_kind(&acp::PermissionOptionKind::AllowAlways),
DefaultSelectedPermission::AllowCommandAlways
);
assert_eq!(
DefaultSelectedPermission::from_kind(&acp::PermissionOptionKind::RejectOnce),
DefaultSelectedPermission::Reject
);
// `RejectAlways` ("No, and don't run X") collapses to Reject so the
// next prompt still lands on its reject row.
assert_eq!(
DefaultSelectedPermission::from_kind(&acp::PermissionOptionKind::RejectAlways),
DefaultSelectedPermission::Reject
);
}
#[test]
fn last_used_round_trips() {
// Fresh thread = un-seeded thread-local (starts at the sentinel).
std::thread::spawn(|| {
assert_eq!(
last_used_permission(),
DefaultSelectedPermission::AlwaysAllowAllSessions
);
set_last_used_permission(DefaultSelectedPermission::AllowCommandAlways);
assert_eq!(
last_used_permission(),
DefaultSelectedPermission::AllowCommandAlways
);
set_last_used_permission(DefaultSelectedPermission::Reject);
assert_eq!(last_used_permission(), DefaultSelectedPermission::Reject);
})
.join()
.unwrap();
}
#[test]
fn resolve_unset_lands_on_yolo_row() {
std::thread::spawn(|| {
// Force the config cache to the default without touching disk/env.
set_default_selected_permission(DefaultSelectedPermission::AlwaysAllowAllSessions);
let options = [
opt("allow-once", acp::PermissionOptionKind::AllowOnce),
opt(
ENABLE_ALWAYS_APPROVE_OPTION_ID,
acp::PermissionOptionKind::AllowOnce,
),
opt("reject-once", acp::PermissionOptionKind::RejectOnce),
];
// No sticky + default config → the enable-always-approve row,
// matched by identity (index 1), not the first AllowOnce (index 0).
assert_eq!(resolve_initial_cursor(&options), 1);
})
.join()
.unwrap();
}
#[test]
fn resolve_sticky_skips_yolo_row() {
std::thread::spawn(|| {
set_default_selected_permission(DefaultSelectedPermission::AlwaysAllowAllSessions);
set_last_used_permission(DefaultSelectedPermission::AllowOnce);
let options = [
opt(
ENABLE_ALWAYS_APPROVE_OPTION_ID,
acp::PermissionOptionKind::AllowOnce,
),
opt("allow-once", acp::PermissionOptionKind::AllowOnce),
opt("reject-once", acp::PermissionOptionKind::RejectOnce),
];
// Sticky AllowOnce must skip the YOLO row (also AllowOnce kind) and
// land on the plain allow-once row (index 1).
assert_eq!(resolve_initial_cursor(&options), 1);
})
.join()
.unwrap();
}
#[test]
fn resolve_sticky_reject_lands_on_reject_always_only_prompt() {
std::thread::spawn(|| {
set_default_selected_permission(DefaultSelectedPermission::AlwaysAllowAllSessions);
set_last_used_permission(DefaultSelectedPermission::Reject);
let options = [
opt(
ENABLE_ALWAYS_APPROVE_OPTION_ID,
acp::PermissionOptionKind::AllowOnce,
),
opt("allow-once", acp::PermissionOptionKind::AllowOnce),
opt("reject-always", acp::PermissionOptionKind::RejectAlways),
];
// The prompt offers only `RejectAlways`; a sticky reject must still
// find it (index 2) rather than falling back to the YOLO row.
assert_eq!(resolve_initial_cursor(&options), 2);
})
.join()
.unwrap();
}
#[test]
fn resolve_without_yolo_row_falls_back_to_index_0() {
std::thread::spawn(|| {
set_default_selected_permission(DefaultSelectedPermission::AlwaysAllowAllSessions);
let options = [
opt("allow-once", acp::PermissionOptionKind::AllowOnce),
opt("reject-once", acp::PermissionOptionKind::RejectOnce),
];
// No sticky, default config, no YOLO row (non-TUI client) → index 0.
assert_eq!(resolve_initial_cursor(&options), 0);
})
.join()
.unwrap();
}
}
@@ -0,0 +1,72 @@
//! The `render_mermaid` user setting (`auto | on | off`).
//!
//! Fenced ` ```mermaid ` blocks are rendered inline as Unicode box-drawing art
//! by the markdown renderer. This setting controls the full-fidelity affordance
//! row layered beneath that art: `auto`/`on` add the clickable row
//! (`◇ mermaid [Open Image] [Copy Image Path] [Copy Source]`); `off` shows the
//! inline art alone. The PNG render engine is always compiled in, and the PNG is
//! never drawn as an inline image (it opens in the OS viewer), so the treatment
//! is identical in every terminal.
/// User preference for rendering Mermaid diagrams.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum RenderMermaid {
/// Show the diagram's clickable affordance row. The default.
#[default]
Auto,
/// Explicit opt-in; behaves identically to [`Auto`](Self::Auto) (terminal
/// capability is never consulted — the affordance row is text + hit-rects).
On,
/// Show the inline diagram art alone, without the affordance row.
Off,
}
impl RenderMermaid {
/// Canonical persisted string (matches the settings-registry choices).
pub fn as_canonical(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::On => "on",
Self::Off => "off",
}
}
/// Parse a canonical string, returning `None` for unrecognized input so
/// callers can fall back to the default rather than guess.
pub fn from_canonical(value: &str) -> Option<Self> {
match value {
"auto" => Some(Self::Auto),
"on" => Some(Self::On),
"off" => Some(Self::Off),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_round_trips() {
for kind in [RenderMermaid::Auto, RenderMermaid::On, RenderMermaid::Off] {
assert_eq!(
RenderMermaid::from_canonical(kind.as_canonical()),
Some(kind)
);
}
}
#[test]
fn default_is_auto() {
assert_eq!(RenderMermaid::default(), RenderMermaid::Auto);
assert_eq!(RenderMermaid::default().as_canonical(), "auto");
}
#[test]
fn unknown_canonical_is_none() {
assert_eq!(RenderMermaid::from_canonical("yes"), None);
assert_eq!(RenderMermaid::from_canonical(""), None);
assert_eq!(RenderMermaid::from_canonical("Auto"), None);
}
}
@@ -0,0 +1,67 @@
//! The `scroll_mode` user setting (`auto` | `wheel` | `trackpad`).
//!
//! Wheel-vs-trackpad detection is heuristic (terminal scroll events carry no
//! magnitude), so this setting lets a user force one classification when the
//! heuristic is wrong for their setup. The pager's input layer maps it onto
//! `ScrollInputMode` when building its scroll config; this crate only owns
//! the persisted value type and its cache.
/// Scroll input classification preference: auto-detect or force one kind.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum ScrollMode {
/// Detect wheel vs trackpad per stream from event timing. Default.
#[default]
Auto,
/// Always treat scroll input as a mouse wheel (fixed lines per tick).
Wheel,
/// Always treat scroll input as a trackpad (fractional accumulation).
Trackpad,
}
impl ScrollMode {
/// Canonical persisted string (matches the settings-registry choices).
pub const fn as_canonical(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Wheel => "wheel",
Self::Trackpad => "trackpad",
}
}
/// Parse a canonical string, returning `None` for unrecognized input.
pub fn from_canonical(value: &str) -> Option<Self> {
match value {
"auto" => Some(Self::Auto),
"wheel" => Some(Self::Wheel),
"trackpad" => Some(Self::Trackpad),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_round_trips() {
for mode in [ScrollMode::Auto, ScrollMode::Wheel, ScrollMode::Trackpad] {
assert_eq!(ScrollMode::from_canonical(mode.as_canonical()), Some(mode));
}
}
#[test]
fn junk_and_case_variants_are_rejected() {
// Strict parse: unknown disk/env values must fall back to the default
// at the caller (cache seed), never panic or mis-map.
for junk in ["", "Auto", "WHEEL", "track pad", "mouse", "1"] {
assert_eq!(ScrollMode::from_canonical(junk), None, "{junk:?}");
}
}
#[test]
fn default_is_auto() {
assert_eq!(ScrollMode::default(), ScrollMode::Auto);
assert_eq!(ScrollMode::default().as_canonical(), "auto");
}
}
@@ -0,0 +1,102 @@
//! The `keep_text_selection` user setting (`flash` | `hold` | `word_select`).
//!
//! This is the single, unified control for scrollback text-selection behavior.
//! It governs both how long an in-app selection highlight stays on screen and
//! what a double/triple-click does, so the two can never drift out of sync:
//!
//! - `flash` — brief highlight on mouse-up, then clear; double-click toggles fold.
//! - `hold` — selection stays until dismissed; double-click toggles fold.
//! - `word_select` — selection stays until dismissed; double-click selects &
//! copies a word, triple-click a line (terminal-like). Implies `hold`.
/// Scrollback text-selection behavior: highlight lifetime + double-click action.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum TextSelection {
/// Brief highlight on mouse-up, then clear; double-click toggles fold. Default.
#[default]
Flash,
/// Stay visible until Esc/click/scroll; double-click toggles fold.
Hold,
/// Stay visible until dismissed; double/triple-click selects & copies a
/// word/line (terminal-like). Implies [`TextSelection::holds`].
WordSelect,
}
impl TextSelection {
/// Canonical persisted string (matches the settings-registry choices).
pub const fn as_canonical(self) -> &'static str {
match self {
Self::Flash => "flash",
Self::Hold => "hold",
Self::WordSelect => "word_select",
}
}
/// Parse a canonical string, returning `None` for unrecognized input.
pub fn from_canonical(value: &str) -> Option<Self> {
match value {
"flash" => Some(Self::Flash),
"hold" => Some(Self::Hold),
"word_select" => Some(Self::WordSelect),
_ => None,
}
}
/// Never timer-dismiss the highlight (`hold` or `word_select`).
pub const fn holds(self) -> bool {
matches!(self, Self::Hold | Self::WordSelect)
}
/// Whether double-click selects & copies a word (and triple-click a line),
/// terminal-style, instead of toggling a fold (`word_select` only).
pub const fn selects_word(self) -> bool {
matches!(self, Self::WordSelect)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_round_trips() {
for kind in [
TextSelection::Flash,
TextSelection::Hold,
TextSelection::WordSelect,
] {
assert_eq!(
TextSelection::from_canonical(kind.as_canonical()),
Some(kind)
);
}
}
#[test]
fn default_is_flash() {
assert_eq!(TextSelection::default(), TextSelection::Flash);
assert_eq!(TextSelection::default().as_canonical(), "flash");
}
/// The unified invariant: `word_select` always implies `holds()` (persistent
/// highlight) and is the only mode that turns on double-click word select.
#[test]
fn word_select_implies_hold_and_word_select() {
assert!(TextSelection::WordSelect.holds());
assert!(TextSelection::WordSelect.selects_word());
// Hold persists but leaves double-click as fold-toggle.
assert!(TextSelection::Hold.holds());
assert!(!TextSelection::Hold.selects_word());
// Flash neither persists nor word-selects.
assert!(!TextSelection::Flash.holds());
assert!(!TextSelection::Flash.selects_word());
}
#[test]
fn unknown_canonical_is_none() {
assert_eq!(TextSelection::from_canonical("yes"), None);
assert_eq!(TextSelection::from_canonical(""), None);
assert_eq!(TextSelection::from_canonical("Flash"), None);
assert_eq!(TextSelection::from_canonical("true"), None);
}
}
@@ -0,0 +1,72 @@
//! File watcher for appearance configuration.
//!
//! In dev mode, watches ~/.kigi/pager.toml for changes and hot-reloads.
//! In prod mode, returns static defaults (no file operations).
use super::config::AppearanceConfig;
use std::io;
use std::path::PathBuf;
use tokio::sync::watch;
/// Watches for appearance config changes.
///
/// In dev mode: reads from ~/.kigi/pager.toml, watches for changes.
/// In prod mode: returns static defaults, `.changed()` never fires.
pub struct ConfigWatcher {
rx: watch::Receiver<AppearanceConfig>,
#[allow(dead_code)]
state: WatcherState,
}
enum WatcherState {
/// No background task (prod mode or dev without notify)
Static {
/// Keep sender alive so channel doesn't close
_tx: watch::Sender<AppearanceConfig>,
},
}
impl ConfigWatcher {
/// Start the config watcher.
///
/// - In dev mode: reads/creates ~/.kigi/pager.toml, watches for changes
/// - In prod mode: returns default config, no file operations
pub async fn start() -> io::Result<Self> {
Self::start_static()
}
/// Get current config.
pub fn current(&self) -> watch::Ref<'_, AppearanceConfig> {
self.rx.borrow()
}
/// Wait for config to change. Never completes in prod mode.
pub async fn changed(&mut self) -> Result<(), watch::error::RecvError> {
self.rx.changed().await
}
/// Path to `$KIGI_SHARE_DIR/pager.toml`.
fn pager_config_path() -> PathBuf {
crate::util::pager_toml_path()
}
/// Start with config loaded from disk (prod mode — no hot-reload).
fn start_static() -> io::Result<Self> {
let config = kigi_config::user_kigi_home()
.and_then(|_| std::fs::read_to_string(Self::pager_config_path()).ok())
.and_then(|content| {
toml::from_str::<super::config::RawAppearanceConfig>(&content)
.ok()
.map(AppearanceConfig::from)
})
.unwrap_or_default();
let (tx, rx) = watch::channel(config);
Ok(Self {
rx,
state: WatcherState::Static { _tx: tx },
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_watcher_start() {
let watcher = ConfigWatcher::start().await.unwrap();
let config = watcher.current();
let _ = config.scrollback.blocks.edit.indent;
let _ = config.scrollback.blocks.edit.vpad;
}
}