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:
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user