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).
370 lines
17 KiB
Rust
370 lines
17 KiB
Rust
use kigi_config_types::DisplayRefreshSettings;
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct UiConfig {
|
||
pub max_thoughts_width: u16,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub theme: Option<String>,
|
||
/// Model ID to use for the secondary agent when forking.
|
||
/// Defaults to the main default model (from default_models.json).
|
||
pub fork_secondary_model: String,
|
||
/// YOLO mode. Read by `util::config`, declared here for `serde_ignored`.
|
||
#[serde(default)]
|
||
pub yolo: bool,
|
||
/// UI theme alias. Read by `util::config`, declared here for `serde_ignored`.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub ui_theme: Option<String>,
|
||
/// Compact mode. Read by pager, declared here for `serde_ignored`.
|
||
#[serde(default)]
|
||
pub compact_mode: bool,
|
||
/// Simple mode. Read by pager, declared here for `serde_ignored`.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub simple_mode: Option<bool>,
|
||
/// Read by `load_permission_mode()`. Declared for `serde_ignored`.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub permission_mode: Option<String>,
|
||
/// Legacy name for `permission_mode`. Declared for `serde_ignored`.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub approval_mode: Option<String>,
|
||
/// Which permission option the cursor preselects on the **first**
|
||
/// permission prompt of a session. One of `allow_once`, `allow_always`,
|
||
/// or `reject`. After the first prompt, the cursor sticks to the user's
|
||
/// last-used option kind. When unset, the first prompt preselects the
|
||
/// "Always allow on all sessions" (enable-always-approve) row. Read by
|
||
/// the pager's permission view.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub default_selected_permission: Option<String>,
|
||
/// Written by the pager's appearance persist module.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub show_timestamps: Option<bool>,
|
||
/// Timeline sidebar (per-turn tick rail in place of the scrollbar).
|
||
/// `None` = off (client default; opt-in). Written by the pager's settings modal.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub show_timeline: Option<bool>,
|
||
/// Theme to use when the OS is in dark mode. Written by the pager's theme persist module.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub auto_dark_theme: Option<String>,
|
||
/// Theme to use when the OS is in light mode. Written by the pager's theme persist module.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub auto_light_theme: Option<String>,
|
||
/// Mouse-wheel and trackpad scroll speed multiplier (1–100).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub scroll_speed: Option<u8>,
|
||
/// Force scroll input classification (`auto` | `wheel` | `trackpad`).
|
||
/// Written by the pager's settings modal; unset defaults to `auto`.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub scroll_mode: Option<String>,
|
||
/// Invert vertical scroll direction ("natural" scrolling).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub invert_scroll: Option<bool>,
|
||
/// Lines per scroll tick, applied to BOTH wheel and trackpad pricing
|
||
/// (1–10). Unset keeps the per-terminal scroll profile's values.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub scroll_lines: Option<u8>,
|
||
/// Vim-style scrollback navigation (hjkl, gg/G, /).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub vim_mode: Option<bool>,
|
||
/// How ` ```mermaid ` code blocks are rendered (`auto` | `on` | `off`).
|
||
/// Written by the pager's settings modal.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub render_mermaid: Option<String>,
|
||
/// Hunk-tracker mode the pager advertises to the agent (`agent_only` |
|
||
/// `all_dirty` | `off`). Written by the pager's settings modal; read at
|
||
/// connect time (CLI `--hunk-tracker-mode` / `KIGI_HUNK_TRACKER` override
|
||
/// it). `off` disables hunk tracking entirely.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub hunk_tracker_mode: Option<String>,
|
||
/// Voice capture chord behavior: `toggle` or `hold` (hold-to-talk; needs a
|
||
/// Kitty-protocol terminal, else falls back to toggle). Written by the
|
||
/// settings modal; unset defaults to `hold`.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub voice_capture_mode: Option<String>,
|
||
/// Speech-to-text language preference for voice dictation. A Grok STT
|
||
/// catalog code (`en`, `es`, `ja`, … — see xAI STT supported languages) or
|
||
/// `auto` (system locale, resolved at connect). Written by the settings
|
||
/// modal; unset leaves `[voice].language` / default `en`. When set, overrides
|
||
/// `[voice].language` for the session.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub voice_stt_language: Option<String>,
|
||
/// When `true`, registers `Ctrl+R` (while scrollback is focused) to toggle
|
||
/// terminal mouse reporting (mouse capture) so users can hand selection back
|
||
/// to the terminal for native click-drag copy/paste. Opt-in only; unset/false
|
||
/// leaves mouse reporting always on with no toggle shortcut. The prompt keeps
|
||
/// `Ctrl+R` for history search — focus scrollback (Esc/Tab) first.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub mouse_reporting_toggle: Option<bool>,
|
||
/// When cancelling a parent turn with running subagents: `always_stop` stops
|
||
/// them without prompting, `always_continue` leaves them running without
|
||
/// prompting. Unset/`ask` shows the cancel-turn picker. Written by the pager
|
||
/// when the user picks "Always stop" / "Always continue".
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub cancel_subagents_on_turn_cancel: Option<String>,
|
||
/// User knob for the `remember_tool_approvals` gate: when `true`, permission
|
||
/// prompts show the granular per-tool "Always allow …" options. Written by
|
||
/// the settings modal; requirements/env/managed/remote settings also feed the
|
||
/// effective gate.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub remember_tool_approvals: Option<bool>,
|
||
/// In-app drag selection highlight: `flash` | `hold` (legacy bool accepted).
|
||
#[serde(
|
||
default,
|
||
skip_serializing_if = "Option::is_none",
|
||
deserialize_with = "deserialize_keep_text_selection"
|
||
)]
|
||
pub keep_text_selection: Option<String>,
|
||
/// Legacy TTL ms; only `Some(0)` counts when `keep_text_selection` is unset.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub selection_highlight_duration_ms: Option<u64>,
|
||
/// Show agent thinking/reasoning blocks in the TUI scrollback.
|
||
/// `None` = on (client default). Written by the pager's settings modal.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub show_thinking_blocks: Option<bool>,
|
||
/// Fold runs of consecutive non-destructive tool calls (reads, searches,
|
||
/// lists) into one transcript row. `None` = on (client default). Written
|
||
/// by the pager's settings modal.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub group_tool_verbs: Option<bool>,
|
||
/// Show Edit tool calls as a collapsed one-line `+N/-M` diffstat summary
|
||
/// by default (expand for the diff). `None` = off (client default).
|
||
/// Written by the pager's settings modal.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub collapsed_edit_blocks: Option<bool>,
|
||
/// Next-prompt suggestions (tab autocomplete ghost text) after each turn.
|
||
/// `None` = on (client default). Written by the pager's settings modal;
|
||
/// the `KIGI_PROMPT_SUGGESTIONS` env var overrides at runtime.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub prompt_suggestions: Option<bool>,
|
||
/// Startup cursor style: `None` (default) inherits the terminal's own
|
||
/// style; `Some(true)` forces the legacy blinking block, `Some(false)` a
|
||
/// steady block. Config-file-only knob (no /settings row).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub cursor_blink: Option<bool>,
|
||
/// `"fullscreen"` | `"minimal"`; unset → product default fullscreen.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub screen_mode: Option<String>,
|
||
/// Retired hidden opt-in for terminal-like double/triple-click word/line
|
||
/// selection. Superseded by `keep_text_selection = "word_select"`. Still
|
||
/// read only when `keep_text_selection` is unset; Settings clears this on
|
||
/// write. `"word_select"` | unset.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub double_click_action: Option<String>,
|
||
/// Per-tip contextual-hint opt-outs (`[ui.contextual_hints]`). Each `None`
|
||
/// inherits the remote/default (on); `Some` is a user-explicit choice that
|
||
/// beats the remote tier. Skipped on the wire when untouched so the section
|
||
/// only appears once a user toggles a tip.
|
||
#[serde(default, skip_serializing_if = "ContextualHints::is_default")]
|
||
pub contextual_hints: ContextualHints,
|
||
/// Display-refresh probe + auto-cadence (`[ui.display_refresh]`). Per-field
|
||
/// `None` inherits remote/default; skipped when untouched.
|
||
#[serde(default, skip_serializing_if = "DisplayRefreshSettings::is_default")]
|
||
pub display_refresh: DisplayRefreshSettings,
|
||
}
|
||
|
||
/// User-config opt-outs for the per-tip contextual hints, serialized as
|
||
/// `[ui.contextual_hints]`. Per-field `None` means "inherit remote/default";
|
||
/// `Some(bool)` is a user-explicit choice (needed so the resolver can let it
|
||
/// beat the remote tier).
|
||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct ContextualHints {
|
||
/// Undo tip (Ctrl+Z after a substantial draft wipe).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub undo: Option<bool>,
|
||
/// Plan-mode nudge (typing a planning keyword).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub plan_mode: Option<bool>,
|
||
/// Clipboard-image input tip.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub image_input: Option<bool>,
|
||
/// Send-now tip after queuing a mid-turn follow-up (InterjectPrompt chord).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub send_now: Option<bool>,
|
||
/// Small-screen tip (`/compact-mode` hint on smallish terminals).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub small_screen: Option<bool>,
|
||
/// Word-select tip after double-clicking scrollback while Text selection
|
||
/// is still fold/nav (`flash` / `hold`).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub word_select: Option<bool>,
|
||
}
|
||
|
||
impl ContextualHints {
|
||
/// True when no tip has a user-explicit value (all inherit). Lets the
|
||
/// section stay absent from `config.toml` until the user toggles a tip.
|
||
pub fn is_default(&self) -> bool {
|
||
self.undo.is_none()
|
||
&& self.plan_mode.is_none()
|
||
&& self.image_input.is_none()
|
||
&& self.send_now.is_none()
|
||
&& self.small_screen.is_none()
|
||
&& self.word_select.is_none()
|
||
}
|
||
}
|
||
|
||
const DEFAULT_MAX_THOUGHTS_WIDTH: u16 = 120;
|
||
|
||
fn deserialize_keep_text_selection<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
|
||
where
|
||
D: serde::Deserializer<'de>,
|
||
{
|
||
#[derive(Deserialize)]
|
||
#[serde(untagged)]
|
||
enum Raw {
|
||
Bool(bool),
|
||
Str(String),
|
||
}
|
||
|
||
Ok(
|
||
Option::<Raw>::deserialize(deserializer)?.map(|raw| match raw {
|
||
Raw::Bool(true) => "hold".to_string(),
|
||
Raw::Bool(false) => "flash".to_string(),
|
||
Raw::Str(s) => s,
|
||
}),
|
||
)
|
||
}
|
||
|
||
impl Default for UiConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
max_thoughts_width: DEFAULT_MAX_THOUGHTS_WIDTH,
|
||
theme: None,
|
||
fork_secondary_model: kigi_models::default_model().to_string(),
|
||
yolo: false,
|
||
ui_theme: None,
|
||
compact_mode: false,
|
||
simple_mode: None,
|
||
permission_mode: None,
|
||
approval_mode: None,
|
||
default_selected_permission: None,
|
||
show_timestamps: None,
|
||
show_timeline: None,
|
||
auto_dark_theme: None,
|
||
auto_light_theme: None,
|
||
scroll_speed: None,
|
||
scroll_mode: None,
|
||
invert_scroll: None,
|
||
scroll_lines: None,
|
||
vim_mode: None,
|
||
render_mermaid: None,
|
||
hunk_tracker_mode: None,
|
||
voice_capture_mode: None,
|
||
voice_stt_language: None,
|
||
mouse_reporting_toggle: None,
|
||
remember_tool_approvals: None,
|
||
cancel_subagents_on_turn_cancel: None,
|
||
keep_text_selection: None,
|
||
selection_highlight_duration_ms: None,
|
||
show_thinking_blocks: None,
|
||
group_tool_verbs: None,
|
||
collapsed_edit_blocks: None,
|
||
prompt_suggestions: None,
|
||
cursor_blink: None,
|
||
screen_mode: None,
|
||
double_click_action: None,
|
||
contextual_hints: ContextualHints::default(),
|
||
display_refresh: DisplayRefreshSettings::default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl UiConfig {
|
||
/// The single source of truth for the timeline-sidebar default (opt-in).
|
||
/// Flip this one line to change the default everywhere.
|
||
///
|
||
// TODO: migrate the other boolean UI settings (show_timestamps,
|
||
// simple_mode, show_thinking_blocks, …) to the same const + resolver
|
||
// pattern. They currently duplicate their default literal across
|
||
// cache.rs / config.rs / defs.rs / setters.rs / registry.rs and rely on
|
||
// the registry drift-guard test to catch mismatches.
|
||
pub const SHOW_TIMELINE_DEFAULT: bool = false;
|
||
|
||
/// Resolved timeline-sidebar setting: the configured value, or
|
||
/// [`Self::SHOW_TIMELINE_DEFAULT`] when unset. The one place the default
|
||
/// is applied — every layer (cache, appearance config, settings modal)
|
||
/// reads through here so they cannot drift.
|
||
pub fn show_timeline_enabled(&self) -> bool {
|
||
self.show_timeline.unwrap_or(Self::SHOW_TIMELINE_DEFAULT)
|
||
}
|
||
|
||
/// True when the highlight should not timer-dismiss (`hold` / `word_select`,
|
||
/// or legacy duration 0).
|
||
pub fn keep_text_selection_enabled(&self) -> bool {
|
||
if let Some(ref s) = self.keep_text_selection {
|
||
return s == "hold" || s == "word_select";
|
||
}
|
||
matches!(self.selection_highlight_duration_ms, Some(0))
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn keep_text_selection_enabled_precedence() {
|
||
let mut ui = UiConfig::default();
|
||
assert!(!ui.keep_text_selection_enabled());
|
||
|
||
ui.selection_highlight_duration_ms = Some(0);
|
||
assert!(ui.keep_text_selection_enabled());
|
||
|
||
ui.selection_highlight_duration_ms = Some(150);
|
||
assert!(!ui.keep_text_selection_enabled());
|
||
|
||
ui.selection_highlight_duration_ms = Some(0);
|
||
ui.keep_text_selection = Some("flash".into());
|
||
assert!(!ui.keep_text_selection_enabled());
|
||
|
||
ui.keep_text_selection = Some("hold".into());
|
||
ui.selection_highlight_duration_ms = Some(999);
|
||
assert!(ui.keep_text_selection_enabled());
|
||
|
||
ui.keep_text_selection = Some("hold".into());
|
||
ui.selection_highlight_duration_ms = None;
|
||
assert!(ui.keep_text_selection_enabled());
|
||
|
||
// `word_select` implies hold (persistent highlight).
|
||
ui.keep_text_selection = Some("word_select".into());
|
||
ui.selection_highlight_duration_ms = None;
|
||
assert!(ui.keep_text_selection_enabled());
|
||
}
|
||
|
||
#[test]
|
||
fn keep_text_selection_deserializes_legacy_bool_and_string() {
|
||
let from_true: UiConfig = serde_json::from_str(r#"{"keep_text_selection": true}"#).unwrap();
|
||
assert_eq!(from_true.keep_text_selection.as_deref(), Some("hold"));
|
||
|
||
let from_false: UiConfig =
|
||
serde_json::from_str(r#"{"keep_text_selection": false}"#).unwrap();
|
||
assert_eq!(from_false.keep_text_selection.as_deref(), Some("flash"));
|
||
|
||
let from_hold: UiConfig =
|
||
serde_json::from_str(r#"{"keep_text_selection": "hold"}"#).unwrap();
|
||
assert_eq!(from_hold.keep_text_selection.as_deref(), Some("hold"));
|
||
|
||
let from_flash: UiConfig =
|
||
serde_json::from_str(r#"{"keep_text_selection": "flash"}"#).unwrap();
|
||
assert_eq!(from_flash.keep_text_selection.as_deref(), Some("flash"));
|
||
}
|
||
|
||
#[test]
|
||
fn display_refresh_nested_deserialize() {
|
||
let ui: UiConfig = serde_json::from_str(
|
||
r#"{"display_refresh": {"auto_cadence_enabled": true, "floor_ms": 7, "probe_enabled": false}}"#,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(ui.display_refresh.auto_cadence_enabled, Some(true));
|
||
assert_eq!(ui.display_refresh.floor_ms, Some(7));
|
||
assert_eq!(ui.display_refresh.probe_enabled, Some(false));
|
||
assert!(!ui.display_refresh.is_default());
|
||
}
|
||
|
||
#[test]
|
||
fn display_refresh_default_is_skipped_shape() {
|
||
assert!(DisplayRefreshSettings::default().is_default());
|
||
let ui = UiConfig::default();
|
||
assert!(ui.display_refresh.is_default());
|
||
}
|
||
}
|