Files
Kigi-CLI/crates/codegen/kigi-shared/src/ui_config.rs
T
ZacharyZhang-NY a02b555e66 docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment
guidelines: delete redundant restatements, decorative banners, change
narration, and end-of-line comments; keep and tighten the crucial ones
(invariants, bug rationale, SAFETY blocks, ported-source attribution).

No functional code changed. Every edit is proven comment-only against the
prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a
separate doctest-fence check. Where removing a comment made rustfmt or clippy
want to re-lay-out adjacent code, the minimal triggering comment is restored so
code tokens stay byte-identical.

Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy
--workspace --all-targets (0 warnings).

Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for
these guidelines (flags banners, end-of-line comments, change narration, and
commented-out code).
2026-07-23 16:55:39 -04:00

358 lines
16 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>,
/// Secondary-agent model when forking; defaults to the main default model
/// (from default_models.json).
pub fork_secondary_model: String,
/// Read by `util::config`, declared here for `serde_ignored`.
#[serde(default)]
pub yolo: bool,
/// Theme alias. Read by `util::config`, declared here for `serde_ignored`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_theme: Option<String>,
/// Read by the pager, declared here for `serde_ignored`.
#[serde(default)]
pub compact_mode: bool,
/// Read by the 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.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_selected_permission: Option<String>,
#[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).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub show_timeline: Option<bool>,
/// Theme used while the OS is in dark mode.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_dark_theme: Option<String>,
/// Theme used while the OS is in light mode.
#[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`);
/// 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`).
#[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`). Read at connect time; CLI `--hunk-tracker-mode`
/// and `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). 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 Kigi STT
/// catalog code (`en`, `es`, `ja`, … — see xAI STT supported languages) or
/// `auto` (system locale, resolved at connect). Unset leaves
/// `[voice].language` / default `en`; when set it 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.
#[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.
/// 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).
#[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).
#[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).
#[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); 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>,
/// Hidden legacy opt-in for terminal-like double/triple-click word/line
/// selection (`"word_select"` | unset), superseded by
/// `keep_text_selection = "word_select"`. Consulted only while
/// `keep_text_selection` is unset; Settings clears it on write.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub double_click_action: Option<String>,
/// Per-tip contextual-hint opt-outs (`[ui.contextual_hints]`).
#[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 {
/// Keeps the section 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);
/// flipping it changes 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;
/// The one place [`Self::SHOW_TIMELINE_DEFAULT`] is applied — cache,
/// appearance config and the settings modal all resolve 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());
}
}