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).
358 lines
16 KiB
Rust
358 lines
16 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>,
|
||
/// 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());
|
||
}
|
||
}
|