Files
Kigi-CLI/crates/codegen/kigi-shared/src/ui_config.rs
T
ZacharyZhang-NY d6c20fc13f 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).
2026-07-17 05:31:01 -04:00

370 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (1100).
#[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
/// (110). 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());
}
}