Synced from monorepo Changes: - grok-shell: send an expired external-provider credential to the sign-in flow, not a 401 loop - pager: clickable ▲ jumps to the top of the response being read - grok-shell: keep a large task log from making the completion message too long - Plan viewer scrollbar: widen grab zone to the border column; fix striped thumb in Terminal.app - pager: poll the tmux probe teardown grace instead of sleeping it - security: vendor-compat MCP kill switch is now actually enforced when reported as on - grok-shell: restore session eviction when a leader client disconnects - Bump rust-toolchain to 1.93.0 - workspace: lexical-normalize permission path patterns before glob matching - pager: reject garbage Enter in the /resume picker - pager: show Mermaid affordances in plan mode preview - pager: drop manage-account link from /session-info - workspace: auto-approve read-only git queries; defer write floor to auto classifier - Add free-form pattern editor to the "Always allow" command prompt - grok-shell: fix /btw caching - pager: Tab walks answers in the ask_user_question card - External-provider auth refresh: single 7s attempt instead of 3×5s - pager: don't resurrect finished background tasks as Running when completion arrives first - pager: report tmux truecolor clamping in Doctor - Fix plan viewer scrollbar click+drag hijacked by comment gutter - pager/shell: stop double Recap after the same last turn - sampler: preserve x-should-retry through stream collection - pager: clear plan-mode indicator immediately when the user approves a plan - pager: tmux does not re-read its config on reattach Source-Revision: 64c4de99cc822b25ce9c54ab5a4f372093d0885d
2535 lines
95 KiB
Rust
2535 lines
95 KiB
Rust
//! Appearance configuration for the pager.
|
||
//!
|
||
//! Two-layer design:
|
||
//! - `RawAppearanceConfig`: Serde-friendly types for TOML (de)serialization
|
||
//! - `AppearanceConfig`: Runtime types with ratatui::Color, BlockBackground, etc.
|
||
|
||
use documented::{Documented, DocumentedFields};
|
||
use ratatui::style::Color;
|
||
use serde::{Deserialize, Serialize};
|
||
use toml_edit::{DocumentMut, Item, RawString};
|
||
use xai_grok_shared::ui_config::UiConfig;
|
||
|
||
// ============================================================================
|
||
// Runtime Config (used by render code)
|
||
// ============================================================================
|
||
|
||
/// Background style for block content area.
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||
pub enum BlockBackground {
|
||
#[default]
|
||
None,
|
||
Light,
|
||
Dark,
|
||
}
|
||
|
||
/// Runtime appearance configuration with resolved types.
|
||
#[derive(Debug, Clone)]
|
||
pub struct AppearanceConfig {
|
||
pub animation: AnimationConfig,
|
||
pub prompt: PromptViewConfig,
|
||
pub scrollback: ScrollbackConfig,
|
||
pub todo: TodoConfig,
|
||
pub turn_status: TurnStatusConfig,
|
||
/// Show timestamps on user/agent messages. Toggled via `/timestamps`.
|
||
pub show_timestamps: bool,
|
||
/// Timeline sidebar (per-turn tick rail). Toggled via `/timeline`.
|
||
pub show_timeline: bool,
|
||
/// Whether hooks & plugins UI is disabled (hides /hooks, /plugins commands
|
||
/// and scrollback annotations). `false` by default (plugins enabled).
|
||
pub disable_plugins: bool,
|
||
/// Always show the "plan" chip in the status bar when plan content is
|
||
/// available, even after the user exits plan mode.
|
||
/// `false` by default (chip hidden once plan mode ends).
|
||
pub show_plan_chip: bool,
|
||
/// Alt-screen (fullscreen) policy from the `[terminal]` section.
|
||
pub alt_screen: crate::terminal::AltScreenMode,
|
||
/// Experimental scrollback-native minimal mode (`[terminal] minimal`).
|
||
pub minimal: bool,
|
||
/// Pinned live-region height (rows) in minimal mode. Clamped to
|
||
/// `[3, term_height - 1]` at runtime.
|
||
pub minimal_live_rows: u16,
|
||
/// Maximum rows a single committed block may occupy in minimal mode before
|
||
/// it is truncated with a "… N more lines" footer.
|
||
pub minimal_max_commit_rows: u16,
|
||
/// Resolved `[terminal] minimal_collapse_thinking`.
|
||
pub minimal_collapse_thinking: bool,
|
||
}
|
||
|
||
impl Default for AppearanceConfig {
|
||
fn default() -> Self {
|
||
RawAppearanceConfig::default().into()
|
||
}
|
||
}
|
||
|
||
/// Turn status line configuration.
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct TurnStatusConfig {
|
||
/// When true, add a 1-line gap between the turn status line and the prompt
|
||
/// widget. Allows visual separation; also enables future background styling
|
||
/// without merging with the prompt's lighter background.
|
||
pub gap: bool,
|
||
}
|
||
|
||
impl Default for TurnStatusConfig {
|
||
fn default() -> Self {
|
||
Self { gap: true }
|
||
}
|
||
}
|
||
|
||
/// Prompt input view configuration (the editor widget, not the scrollback block).
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct PromptViewConfig {
|
||
/// When true, the prompt collapses to its minimum height (single-line)
|
||
/// when focus is in the scrollback pane. Expands back when focused.
|
||
pub collapse_unfocused: bool,
|
||
/// Show hover highlight box when mousing over the prompt widget.
|
||
pub mouse_hover: bool,
|
||
/// Show the ❯ prefix character in the prompt editor.
|
||
pub show_prefix: bool,
|
||
/// Compact mode: remove top padding and reduce info block padding.
|
||
/// Toggled at runtime via `/compact-mode`. This is the DERIVED render
|
||
/// value, which the app may force on for short terminals (the persisted
|
||
/// user setting is `UiConfig::compact_mode`) — in the pager, write it
|
||
/// only via `AppView::apply_effective_compact`.
|
||
pub compact: bool,
|
||
}
|
||
|
||
impl Default for PromptViewConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
collapse_unfocused: true,
|
||
mouse_hover: true,
|
||
show_prefix: true,
|
||
compact: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Scrollback pane configuration (layout, scrollbar, scroll, block rendering).
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct ScrollbackConfig {
|
||
pub layout: LayoutConfig,
|
||
pub scrollbar: ScrollbarConfig,
|
||
pub scroll: ScrollConfig,
|
||
pub blocks: BlocksConfig,
|
||
pub display: ScrollbackDisplayConfig,
|
||
}
|
||
|
||
/// Scrollback display options (grouping, accents, etc.).
|
||
#[derive(Debug, Clone)]
|
||
pub struct ScrollbackDisplayConfig {
|
||
/// Render a subtle horizontal line below the last entry.
|
||
/// Visual marker for "end of content".
|
||
pub line_under_last_entry: bool,
|
||
/// Accent character for collapsed groupable blocks (default: "❙").
|
||
/// Used instead of "┃" to prevent adjacent accents from merging visually.
|
||
pub collapsed_accent_char: String,
|
||
/// Blend factor for dimmed accents on collapsed groupable blocks (0.0–1.0).
|
||
/// 0.0 = invisible (fully bg), 1.0 = full accent color. Default: 0.5.
|
||
pub dim_accent: f32,
|
||
/// Group selection box mode.
|
||
/// When `true` (Mode B / "split"): selection box wraps only the contiguous
|
||
/// collapsed sub-group around the selected entry. Expanded blocks within a
|
||
/// group get their own individual selection box.
|
||
/// When `false` (Mode A / "always"): selection box wraps the entire group
|
||
/// regardless of expanded blocks.
|
||
/// Default: `true` (Mode B).
|
||
pub group_selection_split: bool,
|
||
/// When true, the active-block highlight within a group extends over the
|
||
/// selection box border columns (│). When false (default), the highlight is
|
||
/// inset by 1 column on each side so the borders remain uncolored.
|
||
pub highlight_overlays_border: bool,
|
||
/// When true, the bullet character of the selected entry is replaced with
|
||
/// an expand indicator (e.g., "›") if the block is foldable and collapsed.
|
||
/// Helps indicate which entries can be expanded with 'l' or 'e'.
|
||
/// Default: true.
|
||
pub expandable_indicator: bool,
|
||
/// When true, also show the expand indicator on running entries that are
|
||
/// in their minimum fold mode (e.g., Truncated for execute/thinking blocks).
|
||
/// The indicator inherits the block's animated accent style (blinking).
|
||
/// Default: true.
|
||
pub expandable_indicator_running: bool,
|
||
/// Character to use as the expand indicator. Default: "›".
|
||
pub expandable_indicator_char: String,
|
||
/// Show ⧉ (copy) and ↗ (view) buttons on the selection box.
|
||
/// Default: false (opt-in while testing).
|
||
pub selection_buttons: bool,
|
||
/// Pin user prompts as sticky headers when scrolled past.
|
||
/// Default: true.
|
||
pub sticky_headers: bool,
|
||
/// Number of spaces to use when expanding tab characters (\t) in content.
|
||
/// Tabs in model output are replaced with this many spaces before rendering.
|
||
/// Default: 4. Set to 0 to pass through tabs unchanged.
|
||
pub tab_width: u8,
|
||
/// Maximum number of visible entries in a group of consecutive collapsed
|
||
/// tool-call / thinking blocks. Older entries beyond this limit are hidden
|
||
/// behind a compact "╶╶ N more" header. 0 disables group truncation.
|
||
/// Default: 10.
|
||
pub group_max_visible: u16,
|
||
}
|
||
|
||
impl Default for ScrollbackDisplayConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
line_under_last_entry: false,
|
||
collapsed_accent_char: crate::glyphs::collapsed_accent().to_string(),
|
||
dim_accent: 0.5,
|
||
group_selection_split: true, // Mode B by default
|
||
highlight_overlays_border: false,
|
||
expandable_indicator: true,
|
||
expandable_indicator_running: true,
|
||
expandable_indicator_char: "›".to_string(),
|
||
selection_buttons: false,
|
||
sticky_headers: true,
|
||
tab_width: 4,
|
||
group_max_visible: 10,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Layout configuration for viewport padding and block spacing.
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct LayoutConfig {
|
||
/// Vertical padding (top/bottom) for outer viewport.
|
||
pub outer_vpad: u16,
|
||
/// Left horizontal padding for outer viewport.
|
||
pub outer_hpad_left: u16,
|
||
/// Right horizontal padding for outer viewport.
|
||
pub outer_hpad_right: u16,
|
||
/// Padding after accent line, before content (inside block bg).
|
||
pub block_pad_left: u16,
|
||
/// Padding after content, at right edge (inside block bg).
|
||
pub block_pad_right: u16,
|
||
}
|
||
|
||
impl Default for LayoutConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
outer_vpad: 1,
|
||
outer_hpad_left: 2,
|
||
outer_hpad_right: 2,
|
||
block_pad_left: 2,
|
||
block_pad_right: 2, // Match left padding for symmetry
|
||
}
|
||
}
|
||
}
|
||
|
||
impl LayoutConfig {
|
||
/// Minimum value for horizontal padding (must have room for selection border).
|
||
pub const MIN_HPAD: u16 = 1;
|
||
|
||
/// Effective outer vertical padding (0 in compact mode).
|
||
pub fn eff_outer_vpad(&self, compact: bool) -> u16 {
|
||
if compact { 0 } else { self.outer_vpad }
|
||
}
|
||
|
||
/// Effective left horizontal padding (MIN_HPAD in compact mode).
|
||
pub fn eff_hpad_left(&self, compact: bool) -> u16 {
|
||
if compact {
|
||
Self::MIN_HPAD
|
||
} else {
|
||
self.outer_hpad_left
|
||
}
|
||
}
|
||
|
||
/// Effective right horizontal padding (MIN_HPAD in compact mode).
|
||
pub fn eff_hpad_right(&self, compact: bool) -> u16 {
|
||
if compact {
|
||
Self::MIN_HPAD
|
||
} else {
|
||
self.outer_hpad_right
|
||
}
|
||
}
|
||
|
||
/// Validate and clamp values to valid ranges.
|
||
pub fn validated(self) -> Self {
|
||
Self {
|
||
outer_vpad: self.outer_vpad,
|
||
outer_hpad_left: self.outer_hpad_left.max(Self::MIN_HPAD),
|
||
outer_hpad_right: self.outer_hpad_right.max(Self::MIN_HPAD),
|
||
block_pad_left: self.block_pad_left,
|
||
block_pad_right: self.block_pad_right,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Scrollbar configuration.
|
||
///
|
||
/// # Positioning
|
||
///
|
||
/// The scrollbar position is computed as:
|
||
/// - `scrollbar_x = screen_right - gap_right - 1`
|
||
/// - Content ends at: `scrollbar_x - gap_left`
|
||
///
|
||
/// # Content Width Clamping
|
||
///
|
||
/// Content width is automatically clamped to not extend beyond the outer
|
||
/// viewport padding. This means:
|
||
/// - With `gap_right=0` (scrollbar at screen edge), the scrollbar is in `outer_hpad_right`
|
||
/// - With `gap_left=0`, content extends to just before the scrollbar
|
||
/// - But content will never exceed `outer_hpad_right` boundary on the right
|
||
///
|
||
/// This allows flexible scrollbar positioning without content overflow.
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct ScrollbarConfig {
|
||
/// Whether scrollbar is enabled.
|
||
pub enabled: bool,
|
||
/// Gap between content/selection edge and scrollbar track.
|
||
/// 0 = adjacent to content, 1+ = space between content and scrollbar.
|
||
pub gap_left: u16,
|
||
/// Gap between scrollbar track and screen edge.
|
||
/// 0 = scrollbar at screen edge (in outer_hpad_right if > 0).
|
||
pub gap_right: u16,
|
||
/// Override scrollbar background color (None = use theme default).
|
||
pub scrollbar_bg: Option<Color>,
|
||
/// Override scrollbar foreground/thumb color (None = use theme default).
|
||
pub scrollbar_fg: Option<Color>,
|
||
}
|
||
|
||
impl Default for ScrollbarConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
enabled: true,
|
||
gap_left: 0, // Content adjacent to scrollbar
|
||
gap_right: 0, // Scrollbar at screen edge
|
||
scrollbar_bg: None,
|
||
scrollbar_fg: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl ScrollbarConfig {
|
||
/// Total width reserved for scrollbar (gap_left + track + gap_right).
|
||
pub fn total_width(&self) -> u16 {
|
||
if self.enabled {
|
||
self.gap_left + 1 + self.gap_right
|
||
} else {
|
||
0
|
||
}
|
||
}
|
||
|
||
/// Whether scrollbar fits entirely within outer_hpad_right.
|
||
pub fn is_outside(&self, outer_hpad_right: u16) -> bool {
|
||
self.gap_right < outer_hpad_right
|
||
}
|
||
}
|
||
|
||
/// Scroll behavior configuration.
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct ScrollConfig {
|
||
/// Minimum lines of context to keep above/below selected entry.
|
||
/// When navigating, ensure at least this many lines of adjacent entries
|
||
/// remain visible. 0 = scroll to edge (default).
|
||
pub margin: u16,
|
||
/// Minimum scroll as a fraction of viewport height (0-100).
|
||
/// If a scroll would be less than this percentage of the viewport,
|
||
/// scroll by this amount instead. 0 = minimal scroll (default).
|
||
/// 100 = always scroll by full page.
|
||
pub min_page_fraction: u8,
|
||
/// Follow indicator style in the gap row below scrollback.
|
||
pub follow_indicator: FollowIndicator,
|
||
/// When follow mode scrolls to new content, auto-select the latest entry.
|
||
pub follow_auto_select: bool,
|
||
/// Scrolling past the bottom (j, Ctrl-D, page-down, mousewheel) engages follow mode.
|
||
pub follow_by_overscroll: bool,
|
||
/// When true (default), expanding/collapsing a block adjusts scroll_offset so
|
||
/// the block's header line stays at the same screen position. When false, uses
|
||
/// ensure_selected_visible (the block may shift on screen).
|
||
pub anchor_on_fold: bool,
|
||
pub respect_manual_folds: bool,
|
||
}
|
||
|
||
impl Default for ScrollConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
margin: 0,
|
||
min_page_fraction: 0,
|
||
follow_indicator: FollowIndicator::Center,
|
||
follow_auto_select: true,
|
||
follow_by_overscroll: true,
|
||
anchor_on_fold: true,
|
||
respect_manual_folds: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Scroll indicator display mode: the ▼ jump-to-bottom arrow below
|
||
/// scrollback and its ▲ jump-to-response-top mirror under the sticky
|
||
/// prompt header.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||
pub enum FollowIndicator {
|
||
/// No scroll indicators.
|
||
None,
|
||
/// Show ▼ centered in the gap row below scrollback when not following
|
||
/// and there's content below the viewport, and ▲ centered under the
|
||
/// sticky prompt header while the answer being read starts above the
|
||
/// viewport top.
|
||
#[default]
|
||
Center,
|
||
}
|
||
|
||
impl ScrollConfig {
|
||
/// Compute the minimum scroll amount in lines for a given viewport height.
|
||
pub fn min_scroll_lines(&self, viewport_height: u16) -> u16 {
|
||
if self.min_page_fraction == 0 {
|
||
0
|
||
} else {
|
||
let fraction =
|
||
(self.min_page_fraction.min(100) as u32) * (viewport_height as u32) / 100;
|
||
fraction as u16
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Animation configuration.
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct AnimationConfig {
|
||
/// Animation frame rate (ticks per second).
|
||
/// Higher = smoother but more CPU. Default: 30.
|
||
pub fps: u8,
|
||
/// Rows per wave cycle for accent line animation.
|
||
/// Lower = faster wave, higher = slower/smoother wave. Default: 32.
|
||
pub wave_rows: u16,
|
||
/// Show an FPS counter overlay in the top-right corner (debug/dev builds only).
|
||
/// Also enabled by the `GROK_FPS=1` env var. Default: false.
|
||
pub show_fps: bool,
|
||
}
|
||
|
||
impl Default for AnimationConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
fps: 30,
|
||
wave_rows: 32,
|
||
show_fps: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl AnimationConfig {
|
||
/// Get the tick interval as a Duration.
|
||
pub fn tick_interval(&self) -> std::time::Duration {
|
||
let fps = self.fps.max(1) as u64;
|
||
std::time::Duration::from_millis(1000 / fps)
|
||
}
|
||
}
|
||
|
||
/// Badge format for the todo status counts in the status bar.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||
pub enum TodoBadgeFormat {
|
||
/// Colon format: `[▶:1 □:4 ✓:3 ✗:2]` — compact, icon:count.
|
||
Colon,
|
||
/// Comma format: `[1 ▶, 4 □, 3 ✓, 2 ✗]` — count icon, comma-separated.
|
||
Comma,
|
||
/// Default format: `2/5` — a `done/total` progress fraction (done =
|
||
/// completed, total = all tasks except cancelled).
|
||
#[default]
|
||
Default,
|
||
}
|
||
|
||
/// Todo pane configuration.
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct TodoConfig {
|
||
/// Badge format in the status bar.
|
||
pub badge_format: TodoBadgeFormat,
|
||
}
|
||
|
||
impl Default for TodoConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
badge_format: TodoBadgeFormat::Default,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct BlocksConfig {
|
||
pub edit: EditBlockConfig,
|
||
pub prompt: PromptConfig,
|
||
pub thinking: ThinkingConfig,
|
||
pub tool: ToolConfig,
|
||
pub list_dir: ListDirConfig,
|
||
pub execute: ExecuteConfig,
|
||
}
|
||
|
||
/// Runtime config for EditBlock with resolved ratatui types.
|
||
#[derive(Debug, Clone)]
|
||
pub struct EditBlockConfig {
|
||
pub indent: bool,
|
||
pub vpad: bool,
|
||
pub bg: BlockBackground,
|
||
pub accent_bg: bool,
|
||
pub accent: Option<Color>,
|
||
pub gutter_bg: bool,
|
||
pub indent_bg: bool,
|
||
/// Show the +N/-M line summary in the collapsed header. `None` (default)
|
||
/// follows the shell-owned `collapsed_edit_blocks` flag; an explicit
|
||
/// pager.toml value pins the shape regardless of the flag.
|
||
pub line_summary: Option<bool>,
|
||
/// When true, Edit blocks start in Expanded mode showing the diff; when
|
||
/// false, they start Collapsed (one-line summary). `None` (default)
|
||
/// follows the shell-owned `collapsed_edit_blocks` flag; an explicit
|
||
/// pager.toml value pins the shape regardless of the flag.
|
||
pub expanded_by_default: Option<bool>,
|
||
/// Separator between diff hunks.
|
||
/// Options: "…" (ellipsis, default), "───" (line), "⋯" (midline), "" (none).
|
||
pub hunk_separator: String,
|
||
/// Show two line-number columns (old + new) like GitHub's unified diff.
|
||
/// When false (default), show a single column with the new-file line number.
|
||
pub dual_line_numbers: bool,
|
||
}
|
||
|
||
impl Default for EditBlockConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
indent: true,
|
||
vpad: false,
|
||
bg: BlockBackground::None,
|
||
accent_bg: false,
|
||
accent: None,
|
||
gutter_bg: false,
|
||
indent_bg: false,
|
||
line_summary: None,
|
||
expanded_by_default: None,
|
||
hunk_separator: "…".to_string(),
|
||
dual_line_numbers: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl EditBlockConfig {
|
||
/// Effective "Edit blocks start expanded" default. The single policy
|
||
/// point pairing the two owners: an explicit pager.toml value wins;
|
||
/// unset defers to the shell-owned `collapsed_edit_blocks` flag
|
||
/// (flag on = collapsed one-liner, off = legacy expanded diff).
|
||
pub fn effective_expanded(&self, collapsed_edit_blocks: bool) -> bool {
|
||
self.expanded_by_default.unwrap_or(!collapsed_edit_blocks)
|
||
}
|
||
|
||
/// Effective collapsed-header `+N/-M` diffstat toggle. Same pairing as
|
||
/// [`Self::effective_expanded`]: explicit value wins; unset shows the
|
||
/// diffstat exactly when the flag collapses Edits (the one-liner view
|
||
/// is what the summary exists for).
|
||
pub fn effective_line_summary(&self, collapsed_edit_blocks: bool) -> bool {
|
||
self.line_summary.unwrap_or(collapsed_edit_blocks)
|
||
}
|
||
}
|
||
|
||
/// Runtime config for user prompt block (rendered inside scrollback).
|
||
#[derive(Debug, Clone)]
|
||
pub struct PromptConfig {
|
||
/// Whether to apply vertical padding (blank lines above/below).
|
||
pub vpad: bool,
|
||
/// Block background color.
|
||
pub bg: BlockBackground,
|
||
/// Whether accent column gets block's background.
|
||
pub accent_bg: bool,
|
||
/// Minimum content lines to show in truncated/sticky header mode.
|
||
/// This is the number of actual content lines, not including vpad.
|
||
pub min_lines: u16,
|
||
/// Show the ❯ prefix character before the prompt text.
|
||
pub show_prefix: bool,
|
||
}
|
||
|
||
impl Default for PromptConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
vpad: true,
|
||
bg: BlockBackground::Light,
|
||
accent_bg: false,
|
||
min_lines: 2,
|
||
show_prefix: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Runtime config for thinking/reasoning block.
|
||
#[derive(Debug, Clone)]
|
||
pub struct ThinkingConfig {
|
||
/// Accent color for the thinking block.
|
||
pub accent: Color,
|
||
/// Whether accent line is enabled. When false, no accent in any mode.
|
||
pub accent_enabled: bool,
|
||
/// How much to blend markdown colors with background (0.0-1.0).
|
||
/// 0.8 means 80% original color, 20% background.
|
||
pub bg_blend: f32,
|
||
/// Number of visual lines to show in truncated mode (before and after ellipsis).
|
||
pub truncated_lines: u16,
|
||
/// Whether the accent line animates (traveling wave) while thinking is active.
|
||
pub animate: bool,
|
||
/// Show header line ("Thinking..." / "Thought for Xs") in all display modes.
|
||
/// When false (default), the header only appears in collapsed mode.
|
||
/// When true, it appears as the first line in truncated and expanded modes too.
|
||
pub header: bool,
|
||
/// When true, the header uses brighter styling in non-collapsed modes
|
||
/// (matching tool block title style), and respects muted_collapsed when collapsed.
|
||
/// When false (default), the header is always dim/muted gray.
|
||
pub header_bright: bool,
|
||
/// Render the reasoning body de-emphasized (SGR dim + italic) on top of the
|
||
/// `bg_blend` fade, for surfaces where the fade alone cannot separate
|
||
/// reasoning from the answer. **Not a TOML key** — minimal mode sets it;
|
||
/// see the minimal-mode design doc §6.16.
|
||
pub body_dim_italic: bool,
|
||
/// Append a dim "(ctrl+e to expand)" affordance to the *collapsed* header
|
||
/// when it fits on the same row (never adds a row). **Not a TOML key** —
|
||
/// minimal mode sets it, being the only surface where a folded block cannot
|
||
/// be unfolded in place.
|
||
pub collapsed_expand_hint: bool,
|
||
}
|
||
|
||
impl Default for ThinkingConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
accent: crate::theme::Theme::current().gray_dim,
|
||
accent_enabled: true,
|
||
bg_blend: 0.7,
|
||
truncated_lines: 3,
|
||
animate: true,
|
||
header: true,
|
||
header_bright: false,
|
||
body_dim_italic: false,
|
||
collapsed_expand_hint: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Runtime config for tool call blocks (Read, Search, ListDir, etc).
|
||
#[derive(Debug, Clone)]
|
||
pub struct ToolConfig {
|
||
/// When true, collapsed tool calls render entirely in muted gray.
|
||
/// When false, collapsed tool calls show normal colors (paths, patterns, etc).
|
||
pub muted_collapsed: bool,
|
||
/// When true, parenthetical details use gray_dim (dimmest gray):
|
||
/// Read "(1-50)", Search "(N matches)", Edit "(N edits)", Thinking "for Xs".
|
||
/// When false, they use the normal muted gray.
|
||
pub dim_details: bool,
|
||
/// Bullet/icon character rendered before tool call headers.
|
||
pub bullet: ToolBullet,
|
||
// Note: bullet_accent and bullet_color were removed in the scrollback-v2 refactor.
|
||
// Bullet color is now determined by BlockContent::bullet() — each block type
|
||
// decides its own bullet color based on state (accent color, error, default).
|
||
// Dimming for collapsed+groupable blocks is handled by EntryRenderer.
|
||
// TODO(dim_muted): add a dim factor for collapsed text styling (not just bullet/accent).
|
||
}
|
||
|
||
impl Default for ToolConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
muted_collapsed: true,
|
||
dim_details: true,
|
||
bullet: ToolBullet::Diamond,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Bullet/icon style for tool call headers.
|
||
///
|
||
/// Rendered before the tool title, e.g. `⊙ Read src/main.rs`.
|
||
/// Respects `muted_collapsed`: when the tool is collapsed and muting is
|
||
/// enabled, the bullet color blends with the muted palette.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||
pub enum ToolBullet {
|
||
/// No bullet (default).
|
||
#[default]
|
||
None,
|
||
/// `·` (middle dot — smallest).
|
||
Dot,
|
||
/// `•` (bullet — between dot and circle).
|
||
SmallCircle,
|
||
/// `●` (filled circle).
|
||
Circle,
|
||
/// `▸` (right-pointing small triangle).
|
||
SmallTriangle,
|
||
/// `▶` (right-pointing triangle).
|
||
Triangle,
|
||
/// `◆` (filled diamond).
|
||
Diamond,
|
||
}
|
||
|
||
impl ToolBullet {
|
||
/// The display character for this bullet, or `None` for no bullet.
|
||
pub fn char(&self) -> Option<&'static str> {
|
||
match self {
|
||
Self::None => Option::None,
|
||
Self::Dot => Some("·"),
|
||
Self::SmallCircle => Some("•"),
|
||
Self::Circle => Some(crate::glyphs::filled_dot()),
|
||
Self::SmallTriangle => Some("▸"),
|
||
Self::Triangle => Some("▶"),
|
||
// Routed through `glyphs` so the default scrollback bullet
|
||
// (used by tool calls, thinking, the running-subagent block,
|
||
// etc.) degrades to the CP437 `♦` on legacy Windows consoles
|
||
// that can't render U+25C6.
|
||
Self::Diamond => Some(crate::glyphs::diamond_filled()),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Runtime config for ListDir block.
|
||
#[derive(Debug, Clone)]
|
||
pub struct ListDirConfig {
|
||
/// When true, output has terminal-style dark background.
|
||
/// When false, output has no background (default).
|
||
pub terminal_bg: bool,
|
||
}
|
||
|
||
impl Default for ListDirConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
terminal_bg: true, // Default: dark background for output
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Header display style for execute blocks.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||
pub enum ExecuteHeaderStyle {
|
||
/// Shell style: `$ command` (default).
|
||
/// The `$` prompt is dim/muted, command may or may not be colored.
|
||
#[default]
|
||
Shell,
|
||
/// Label style: `Run command` (like Edit/Search blocks).
|
||
/// "Run" is bold (muted when collapsed, primary when expanded).
|
||
Label,
|
||
}
|
||
|
||
/// Runtime config for Execute tool call block.
|
||
#[derive(Debug, Clone)]
|
||
pub struct ExecuteConfig {
|
||
/// Number of output lines to show at the start in truncated mode.
|
||
pub first_lines: u16,
|
||
/// Number of output lines to show at the end in truncated mode.
|
||
pub last_lines: u16,
|
||
/// Whether accent line is enabled. When false, no accent (running/success/error).
|
||
pub accent_enabled: bool,
|
||
/// Accent color for running execute blocks (animated).
|
||
pub running_accent: Color,
|
||
/// Header display style (shell `$` vs label `Run`).
|
||
pub header_style: ExecuteHeaderStyle,
|
||
/// When true, command text is muted/uncolored when collapsed.
|
||
pub muted_command_collapsed: bool,
|
||
}
|
||
|
||
impl Default for ExecuteConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
first_lines: 2,
|
||
last_lines: 3,
|
||
accent_enabled: true,
|
||
running_accent: crate::theme::Theme::current().accent_running,
|
||
header_style: ExecuteHeaderStyle::Label,
|
||
muted_command_collapsed: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Raw Config (for TOML serde)
|
||
// ============================================================================
|
||
//
|
||
// ╔═══════════════════════════════════════════════════════════════════════════╗
|
||
// ║ MAINTAINER NOTE: When adding/changing fields or sections: ║
|
||
// ║ ║
|
||
// ║ 1. Add doc comments (///) to ALL fields in Raw* structs - they become ║
|
||
// ║ TOML comments via the `DocumentedFields` derive macro. ║
|
||
// ║ ║
|
||
// ║ 2. If adding a new section (e.g., RawNewBlockConfig): ║
|
||
// ║ - Add it to RawBlocksConfig (or appropriate parent) ║
|
||
// ║ - Add corresponding runtime config (NewBlockConfig) ║
|
||
// ║ - Add From<RawNewBlockConfig> for NewBlockConfig conversion ║
|
||
// ║ - Add annotate_table call in to_toml_with_comments() below! ║
|
||
// ║ ║
|
||
// ║ 3. The to_toml_with_comments() method generates the default config file ║
|
||
// ║ with comments. Update it when adding new sections. ║
|
||
// ╚═══════════════════════════════════════════════════════════════════════════╝
|
||
|
||
/// Root appearance configuration (TOML format).
|
||
#[derive(Debug, Clone, Default, Serialize, Deserialize, Documented, DocumentedFields)]
|
||
#[serde(default)]
|
||
pub struct RawAppearanceConfig {
|
||
/// Terminal behavior settings (fullscreen policy).
|
||
pub terminal: RawTerminalConfig,
|
||
/// Animation settings.
|
||
pub animation: RawAnimationConfig,
|
||
/// Prompt input view settings (collapse, hover).
|
||
pub prompt: RawPromptViewConfig,
|
||
/// Scrollback pane settings (layout, scrollbar, scroll, blocks).
|
||
pub scrollback: RawScrollbackConfig,
|
||
/// Todo pane settings (badge format).
|
||
pub todo: RawTodoConfig,
|
||
/// Disable hooks & plugins UI (/hooks and /plugins commands, scrollback annotations).
|
||
/// Defaults to false (plugins enabled).
|
||
pub disable_plugins: bool,
|
||
/// Always show the "plan" chip in the status bar when plan content is
|
||
/// available, even after the user exits plan mode.
|
||
/// Defaults to false (chip hidden once plan mode ends).
|
||
pub show_plan_chip: bool,
|
||
}
|
||
|
||
/// Terminal behavior configuration (TOML format).
|
||
///
|
||
/// Controls fullscreen (alternate screen) policy and related terminal
|
||
/// interaction settings.
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Documented, DocumentedFields)]
|
||
#[serde(default)]
|
||
pub struct RawTerminalConfig {
|
||
/// Alt-screen (fullscreen) policy.
|
||
/// "auto" — fullscreen in plain terminals and normal tmux, inline in
|
||
/// tmux control mode and Zellij. (default)
|
||
/// "always" — always enter fullscreen, even in control mode / Zellij.
|
||
/// "never" — never enter fullscreen; run inline in main scrollback.
|
||
pub alt_screen: RawAltScreenMode,
|
||
/// Experimental scrollback-native rendering mode. Finalized blocks are
|
||
/// printed into the terminal's native scrollback. Default false.
|
||
pub minimal: bool,
|
||
/// Pinned live-region height (rows) for minimal mode. Default 10.
|
||
pub minimal_live_rows: Option<u16>,
|
||
/// Maximum rows for a single committed block in minimal mode. Default 2000.
|
||
pub minimal_max_commit_rows: Option<u16>,
|
||
/// Commit reasoning ("Thought for Xs") to native scrollback COLLAPSED to
|
||
/// its one-line header instead of in full. Default false — minimal
|
||
/// deliberately keeps the whole reasoning body in the transcript (K9); this
|
||
/// is the opt-out for a terser scrollback. The body stays reachable with
|
||
/// `Ctrl+E` / `/expand` and `/transcript`.
|
||
pub minimal_collapse_thinking: bool,
|
||
}
|
||
|
||
impl Default for RawTerminalConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
alt_screen: RawAltScreenMode::Auto,
|
||
minimal: false,
|
||
minimal_live_rows: None,
|
||
minimal_max_commit_rows: None,
|
||
minimal_collapse_thinking: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Raw alt-screen mode for TOML (de)serialization.
|
||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum RawAltScreenMode {
|
||
/// Automatic: fullscreen in healthy environments, inline in degraded ones.
|
||
#[default]
|
||
Auto,
|
||
/// Always enter the alternate screen.
|
||
Always,
|
||
/// Never enter the alternate screen.
|
||
Never,
|
||
}
|
||
|
||
impl From<RawAltScreenMode> for crate::terminal::AltScreenMode {
|
||
fn from(raw: RawAltScreenMode) -> Self {
|
||
match raw {
|
||
RawAltScreenMode::Auto => crate::terminal::AltScreenMode::Auto,
|
||
RawAltScreenMode::Always => crate::terminal::AltScreenMode::Always,
|
||
RawAltScreenMode::Never => crate::terminal::AltScreenMode::Never,
|
||
}
|
||
}
|
||
}
|
||
/// Prompt input view configuration (TOML format).
|
||
///
|
||
/// This configures the prompt editor widget at the bottom of the screen,
|
||
/// NOT the user prompt block rendered inside the scrollback.
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Documented, DocumentedFields)]
|
||
#[serde(default)]
|
||
pub struct RawPromptViewConfig {
|
||
/// When true, the prompt collapses to its minimum height (single-line)
|
||
/// when focus is in the scrollback pane. Expands back when focused.
|
||
pub collapse_unfocused: bool,
|
||
/// Show hover highlight box when mousing over the prompt widget.
|
||
pub mouse_hover: bool,
|
||
/// Show the ❯ prefix character in the prompt editor.
|
||
pub show_prefix: bool,
|
||
}
|
||
|
||
impl Default for RawPromptViewConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
collapse_unfocused: true,
|
||
mouse_hover: true,
|
||
show_prefix: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Scrollback pane configuration (TOML format).
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Default, Documented, DocumentedFields)]
|
||
#[serde(default)]
|
||
pub struct RawScrollbackConfig {
|
||
/// Layout settings (padding, spacing).
|
||
pub layout: RawLayoutConfig,
|
||
/// Scrollbar settings.
|
||
pub scrollbar: RawScrollbarConfig,
|
||
/// Scroll behavior settings.
|
||
pub scroll: RawScrollConfig,
|
||
/// Block rendering settings.
|
||
pub blocks: RawBlocksConfig,
|
||
/// Miscellaneous display options.
|
||
pub display: RawScrollbackDisplayConfig,
|
||
}
|
||
|
||
/// Scrollback display options (TOML format).
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Documented, DocumentedFields)]
|
||
#[serde(default)]
|
||
pub struct RawScrollbackDisplayConfig {
|
||
/// Render a subtle horizontal line below the last entry ("end of content" marker).
|
||
pub line_under_last_entry: bool,
|
||
/// Accent character for collapsed groupable blocks. Default: "❙".
|
||
pub collapsed_accent_char: Option<String>,
|
||
/// Blend factor for dimmed accents on collapsed groupable blocks (0.0–1.0). Default: 0.5.
|
||
pub dim_accent: Option<f32>,
|
||
/// Group selection box mode. true = "split" (Mode B), false = "always" (Mode A). Default: true.
|
||
pub group_selection_split: Option<bool>,
|
||
/// Whether active-block highlight overlays selection box borders. Default: false.
|
||
pub highlight_overlays_border: Option<bool>,
|
||
/// Show expand indicator (e.g., "›") on selected foldable collapsed entries. Default: true.
|
||
pub expandable_indicator: Option<bool>,
|
||
/// Show expand indicator on running entries in their minimum fold mode. Default: true.
|
||
pub expandable_indicator_running: Option<bool>,
|
||
/// Character for the expand indicator. Default: "›".
|
||
pub expandable_indicator_char: Option<String>,
|
||
/// Show ⧉/↗ buttons on the selection box. Default: false.
|
||
pub selection_buttons: Option<bool>,
|
||
/// Pin user prompts as sticky headers when scrolled past. Default: true.
|
||
pub sticky_headers: Option<bool>,
|
||
/// Number of spaces to use when expanding tab characters (\t) in content.
|
||
/// Tabs in model output are replaced with this many spaces before rendering.
|
||
/// Default: 4. Set to 0 to pass through tabs unchanged.
|
||
pub tab_width: Option<u8>,
|
||
/// Maximum visible entries in a consecutive group of collapsed tool/thinking blocks.
|
||
/// Older entries are hidden behind a compact header. 0 = disable. Default: 10.
|
||
pub group_max_visible: Option<u16>,
|
||
}
|
||
|
||
impl Default for RawScrollbackDisplayConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
line_under_last_entry: false,
|
||
collapsed_accent_char: Some(crate::glyphs::collapsed_accent().to_string()),
|
||
dim_accent: Some(0.5),
|
||
group_selection_split: Some(true),
|
||
highlight_overlays_border: Some(false),
|
||
expandable_indicator: Some(true),
|
||
expandable_indicator_running: Some(true),
|
||
expandable_indicator_char: Some("›".to_string()),
|
||
selection_buttons: Some(false),
|
||
sticky_headers: Some(true),
|
||
tab_width: Some(4),
|
||
group_max_visible: Some(10),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Layout configuration (TOML format).
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Documented, DocumentedFields)]
|
||
#[serde(default)]
|
||
pub struct RawLayoutConfig {
|
||
/// Vertical padding (top/bottom) for outer viewport.
|
||
pub outer_vpad: u16,
|
||
/// Left horizontal padding for outer viewport (min 1).
|
||
pub outer_hpad_left: u16,
|
||
/// Right horizontal padding for outer viewport (min 1).
|
||
pub outer_hpad_right: u16,
|
||
/// Padding after accent line, before content.
|
||
pub block_pad_left: u16,
|
||
/// Padding after content, at right edge.
|
||
pub block_pad_right: u16,
|
||
}
|
||
|
||
impl Default for RawLayoutConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
outer_vpad: 1,
|
||
outer_hpad_left: 2,
|
||
outer_hpad_right: 2,
|
||
block_pad_left: 2,
|
||
block_pad_right: 2,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Scrollbar configuration (TOML format).
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Documented, DocumentedFields)]
|
||
#[serde(default)]
|
||
pub struct RawScrollbarConfig {
|
||
/// Whether scrollbar is enabled.
|
||
pub enabled: bool,
|
||
/// Gap between content/selection edge and scrollbar track.
|
||
/// 0 = adjacent to content, 1+ = space between.
|
||
/// Note: Content width is clamped to outer_hpad boundaries.
|
||
pub gap_left: u16,
|
||
/// Gap between scrollbar track and screen edge.
|
||
/// 0 = scrollbar at screen edge (uses outer_hpad_right if available).
|
||
pub gap_right: u16,
|
||
/// Override scrollbar background color.
|
||
/// Use "none" to use theme default, or a color value.
|
||
pub scrollbar_bg: OptionalColor,
|
||
/// Override scrollbar foreground/thumb color.
|
||
/// Use "none" to use theme default, or a color value.
|
||
pub scrollbar_fg: OptionalColor,
|
||
}
|
||
|
||
impl Default for RawScrollbarConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
enabled: true,
|
||
gap_left: 0,
|
||
gap_right: 0,
|
||
scrollbar_bg: OptionalColor::None,
|
||
scrollbar_fg: OptionalColor::None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Scroll behavior configuration (TOML format).
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Documented, DocumentedFields)]
|
||
#[serde(default)]
|
||
pub struct RawScrollConfig {
|
||
/// Minimum lines of context to keep above/below selected entry.
|
||
/// When navigating, ensure at least this many lines of adjacent entries
|
||
/// remain visible. 0 = scroll to edge (default).
|
||
pub margin: u16,
|
||
/// Minimum scroll as percentage of viewport height (0-100).
|
||
/// If a scroll would be less than this percentage, scroll by this amount instead.
|
||
/// 0 = minimal scroll (default), 25 = quarter page, 100 = full page.
|
||
pub min_page_fraction: u8,
|
||
/// Scroll indicators: the ▼ below scrollback and the ▲ under the sticky
|
||
/// prompt header. "none" = hidden, "center" = centered arrows.
|
||
pub follow_indicator: RawFollowIndicator,
|
||
/// When follow mode scrolls to new content, auto-select the latest entry.
|
||
pub follow_auto_select: bool,
|
||
/// Scrolling past the bottom (j, Ctrl-D, page-down, mousewheel) engages follow mode.
|
||
pub follow_by_overscroll: bool,
|
||
/// Anchor scroll on fold: keep the toggled block's header at the same screen y. Default: true.
|
||
pub anchor_on_fold: bool,
|
||
/// Opt-in: keep manually folded blocks as-is during streaming and when they finish,
|
||
/// and stop auto-scroll when a fold expands a block while following. Default: false.
|
||
pub respect_manual_folds: bool,
|
||
}
|
||
|
||
impl Default for RawScrollConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
margin: 0,
|
||
min_page_fraction: 0,
|
||
follow_indicator: RawFollowIndicator::Center,
|
||
follow_auto_select: true,
|
||
follow_by_overscroll: true,
|
||
anchor_on_fold: true,
|
||
respect_manual_folds: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Scroll indicator display mode (TOML format).
|
||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum RawFollowIndicator {
|
||
/// No scroll indicators.
|
||
None,
|
||
/// Show ▼ centered below scrollback and ▲ under the sticky prompt header.
|
||
#[default]
|
||
Center,
|
||
}
|
||
|
||
/// Tool bullet style (TOML format).
|
||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "kebab-case")]
|
||
pub enum RawToolBullet {
|
||
/// No bullet.
|
||
#[default]
|
||
None,
|
||
/// `·` (middle dot — smallest).
|
||
Dot,
|
||
/// `•` (bullet — between dot and circle).
|
||
SmallCircle,
|
||
/// `●` (filled circle).
|
||
Circle,
|
||
/// `▸` (right-pointing small triangle).
|
||
SmallTriangle,
|
||
/// `▶` (right-pointing triangle).
|
||
Triangle,
|
||
/// `◆` (filled diamond).
|
||
Diamond,
|
||
}
|
||
|
||
/// Todo pane configuration (TOML format).
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Documented, DocumentedFields)]
|
||
#[serde(default)]
|
||
pub struct RawTodoConfig {
|
||
/// Badge format in the status bar.
|
||
/// "default" = colored numbers only [1 2 3 4].
|
||
/// "colon" = icon:count [▶:1 □:4 ✓:3].
|
||
/// "comma" = count icon, comma-separated [1 ▶, 4 □, 3 ✓].
|
||
pub badge_format: RawTodoBadgeFormat,
|
||
}
|
||
|
||
impl Default for RawTodoConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
badge_format: RawTodoBadgeFormat::Default,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Badge format for the todo status counts (TOML format).
|
||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum RawTodoBadgeFormat {
|
||
/// Colored numbers only: `[1 2 3 4]`.
|
||
#[default]
|
||
Default,
|
||
/// Icon:count: `[▶:1 □:4 ✓:3 ✗:2]`.
|
||
Colon,
|
||
/// Count icon, comma-separated: `[1 ▶, 4 □, 3 ✓, 2 ✗]`.
|
||
Comma,
|
||
}
|
||
|
||
impl From<RawTodoBadgeFormat> for TodoBadgeFormat {
|
||
fn from(raw: RawTodoBadgeFormat) -> Self {
|
||
match raw {
|
||
RawTodoBadgeFormat::Default => TodoBadgeFormat::Default,
|
||
RawTodoBadgeFormat::Colon => TodoBadgeFormat::Colon,
|
||
RawTodoBadgeFormat::Comma => TodoBadgeFormat::Comma,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Animation configuration (TOML format).
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Documented, DocumentedFields)]
|
||
#[serde(default)]
|
||
pub struct RawAnimationConfig {
|
||
/// Animation frame rate (ticks per second).
|
||
/// Higher = smoother but more CPU. Range: 1-60. Default: 30.
|
||
pub fps: u8,
|
||
/// Rows per wave cycle for accent line animation.
|
||
/// Lower = faster wave, higher = slower/smoother wave. Default: 32.
|
||
pub wave_rows: u16,
|
||
/// Show an FPS counter overlay in the top-right corner.
|
||
/// Requires a debug build. Also enabled by GROK_FPS=1 env var. Default: false.
|
||
pub show_fps: bool,
|
||
}
|
||
|
||
impl Default for RawAnimationConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
fps: 30,
|
||
wave_rows: 32,
|
||
show_fps: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Configuration for all block types (TOML format).
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Default, Documented, DocumentedFields)]
|
||
#[serde(default)]
|
||
pub struct RawBlocksConfig {
|
||
/// Edit block settings.
|
||
pub edit: RawEditBlockConfig,
|
||
/// User prompt block settings.
|
||
pub prompt: RawPromptConfig,
|
||
/// Thinking/reasoning block settings.
|
||
pub thinking: RawThinkingConfig,
|
||
/// Tool call block settings (Read, Search, ListDir, etc).
|
||
pub tool: RawToolConfig,
|
||
/// ListDir block settings.
|
||
pub list_dir: RawListDirConfig,
|
||
/// Execute tool call settings.
|
||
pub execute: RawExecuteConfig,
|
||
}
|
||
|
||
/// Configuration for EditBlock rendering (TOML format).
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Documented, DocumentedFields)]
|
||
#[serde(default)]
|
||
pub struct RawEditBlockConfig {
|
||
/// Whether to apply 2-char indent before line numbers.
|
||
pub indent: bool,
|
||
/// Whether to apply vertical padding.
|
||
pub vpad: bool,
|
||
/// Block background: "none", "dark", or "light".
|
||
pub bg: RawBlockBackground,
|
||
/// Whether to show background behind accent.
|
||
pub accent_bg: bool,
|
||
/// Accent color for vertical line.
|
||
/// Use "none" to disable, or a color value.
|
||
/// Formats: [r, g, b], "#rrggbb", "#rgb", or named color.
|
||
/// Named: BLUE, CYAN, GREEN, YELLOW, ORANGE, RED, MAGENTA, COMMENT, etc.
|
||
pub accent: OptionalColor,
|
||
/// Whether diff line background extends to include gutter (line numbers).
|
||
pub gutter_bg: bool,
|
||
/// Whether to skip indent columns in diff line background (keep them clean).
|
||
/// true = skip indent, false = include indent in background.
|
||
pub indent_bg: bool,
|
||
/// Show the +N/-M line summary in the collapsed header.
|
||
/// Commented out (unset), it follows the `[ui] collapsed_edit_blocks`
|
||
/// flag in config.toml; uncomment to pin either way.
|
||
pub line_summary: Option<bool>,
|
||
/// Start Edit blocks expanded (showing the diff) instead of as a
|
||
/// collapsed one-line summary. Commented out (unset), it follows the
|
||
/// `[ui] collapsed_edit_blocks` flag in config.toml (flag on =
|
||
/// collapsed); uncomment to pin either way.
|
||
pub expanded_by_default: Option<bool>,
|
||
/// Separator between diff hunks. Options: "…" (default), "───", "⋯", "" (none).
|
||
pub hunk_separator: Option<String>,
|
||
/// Show two line-number columns (old + new) like GitHub's unified diff.
|
||
/// When false (default), show a single column with the new-file line number.
|
||
pub dual_line_numbers: bool,
|
||
}
|
||
|
||
impl Default for RawEditBlockConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
indent: true,
|
||
vpad: false,
|
||
bg: RawBlockBackground::None,
|
||
accent_bg: false,
|
||
accent: OptionalColor::None,
|
||
gutter_bg: false,
|
||
indent_bg: false,
|
||
line_summary: None,
|
||
expanded_by_default: None,
|
||
hunk_separator: Some("…".to_string()),
|
||
dual_line_numbers: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Configuration for user prompt block rendering (TOML format).
|
||
///
|
||
/// This configures how user prompts appear inside the scrollback,
|
||
/// NOT the prompt editor widget (see [prompt] section).
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Documented, DocumentedFields)]
|
||
#[serde(default)]
|
||
pub struct RawPromptConfig {
|
||
/// Whether to apply vertical padding (blank lines above/below).
|
||
pub vpad: bool,
|
||
/// Block background: "none", "dark", or "light".
|
||
pub bg: RawBlockBackground,
|
||
/// Whether accent column gets block's background.
|
||
pub accent_bg: bool,
|
||
/// Minimum content lines to show in truncated/sticky header mode.
|
||
/// This is the number of actual content lines, not including vpad.
|
||
pub min_lines: u16,
|
||
/// Show the ❯ prefix character before the prompt text.
|
||
pub show_prefix: bool,
|
||
}
|
||
|
||
impl Default for RawPromptConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
vpad: true,
|
||
bg: RawBlockBackground::Light,
|
||
accent_bg: false,
|
||
min_lines: 2,
|
||
show_prefix: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Configuration for thinking/reasoning block rendering (TOML format).
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Documented, DocumentedFields)]
|
||
#[serde(default)]
|
||
pub struct RawThinkingConfig {
|
||
/// Accent color for the vertical line.
|
||
/// Use "default" (or "none") for theme default, or a named/hex/RGB color.
|
||
pub accent: OptionalColor,
|
||
/// Whether accent line is shown. Set to false to hide accent in all modes.
|
||
pub accent_enabled: bool,
|
||
/// Blend factor for markdown colors with background (0-100).
|
||
/// Lower values = more faded. 70 = 70% original color, 30% background.
|
||
pub bg_blend: u8,
|
||
/// Number of visual lines to show at start and end in truncated mode.
|
||
/// If content exceeds this, shows first N lines, ellipsis, last N lines.
|
||
pub truncated_lines: u16,
|
||
/// Whether the accent line animates (traveling wave) while thinking is active.
|
||
/// When false, the accent line is static.
|
||
pub animate: bool,
|
||
/// Show header line in truncated/expanded modes.
|
||
/// When true: "Thinking..." (running) or "Thought for Xs" (done) appears
|
||
/// as the first line above thinking content, with accent line and bullet.
|
||
pub header: bool,
|
||
/// When true, header uses brighter styling in non-collapsed modes (like tool titles).
|
||
/// Respects muted_collapsed when collapsed. When false, header is always dim gray.
|
||
pub header_bright: bool,
|
||
}
|
||
|
||
impl Default for RawThinkingConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
accent: OptionalColor::None,
|
||
accent_enabled: true,
|
||
bg_blend: 70,
|
||
truncated_lines: 3,
|
||
animate: true,
|
||
header: true,
|
||
header_bright: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Configuration for tool call blocks (TOML format).
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Documented, DocumentedFields)]
|
||
#[serde(default)]
|
||
pub struct RawToolConfig {
|
||
/// When true, collapsed tool calls render entirely in muted gray.
|
||
/// When false, collapsed tool calls show normal colors.
|
||
pub muted_collapsed: bool,
|
||
/// When true, parenthetical details use the dimmest gray (gray_dim):
|
||
/// Read "(1-50)", Search "(N matches)", Edit "(N edits)", Thinking "for Xs".
|
||
/// When false, they use the normal muted gray.
|
||
pub dim_details: bool,
|
||
/// Bullet/icon before tool call headers.
|
||
/// "none", "dot" (·), "small-circle" (•), "circle" (●),
|
||
/// "small-triangle" (▸), "triangle" (▶), "diamond" (◆).
|
||
pub bullet: RawToolBullet,
|
||
// Note: bullet_accent and bullet_color removed — see ToolConfig comment.
|
||
}
|
||
|
||
impl Default for RawToolConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
muted_collapsed: true,
|
||
dim_details: true,
|
||
bullet: RawToolBullet::Diamond,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Configuration for ListDir block (TOML format).
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Documented, DocumentedFields)]
|
||
#[serde(default)]
|
||
pub struct RawListDirConfig {
|
||
/// When true, output has terminal-style dark background.
|
||
/// When false, output has no background.
|
||
pub terminal_bg: bool,
|
||
}
|
||
|
||
impl Default for RawListDirConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
terminal_bg: true, // Default: dark background for output
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Configuration for Execute tool call block (TOML format).
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Documented, DocumentedFields)]
|
||
#[serde(default)]
|
||
pub struct RawExecuteConfig {
|
||
/// Number of output lines to show at the start in truncated mode.
|
||
/// In truncated mode: first_lines, then "…", then last_lines.
|
||
pub first_lines: u16,
|
||
/// Number of output lines to show at the end in truncated mode.
|
||
pub last_lines: u16,
|
||
/// Whether accent line is shown. Set to false to hide all accents
|
||
/// (running/success/error).
|
||
pub accent_enabled: bool,
|
||
/// Accent color for running execute blocks (animated wave).
|
||
/// Default: MAGENTA. Use named colors, hex, or RGB array.
|
||
pub running_accent: OptionalColor,
|
||
/// Header display style: "shell" = `$ command` (default), "label" = `Run command`.
|
||
/// Shell style shows a dim `$` prompt. Label style shows bold "Run" like Edit/Search.
|
||
pub header_style: RawExecuteHeaderStyle,
|
||
/// When true, command text is muted/uncolored when collapsed.
|
||
/// When false, command text keeps its color when collapsed.
|
||
pub muted_command_collapsed: bool,
|
||
}
|
||
|
||
impl Default for RawExecuteConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
first_lines: 2,
|
||
last_lines: 3,
|
||
accent_enabled: true,
|
||
running_accent: OptionalColor::None,
|
||
header_style: RawExecuteHeaderStyle::Label,
|
||
muted_command_collapsed: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Raw header style for execute blocks (TOML format).
|
||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum RawExecuteHeaderStyle {
|
||
/// Shell style: `$ command` (default).
|
||
#[default]
|
||
Shell,
|
||
/// Label style: `Run command` (like Edit/Search blocks).
|
||
Label,
|
||
}
|
||
|
||
impl From<RawExecuteHeaderStyle> for ExecuteHeaderStyle {
|
||
fn from(raw: RawExecuteHeaderStyle) -> Self {
|
||
match raw {
|
||
RawExecuteHeaderStyle::Shell => ExecuteHeaderStyle::Shell,
|
||
RawExecuteHeaderStyle::Label => ExecuteHeaderStyle::Label,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum RawBlockBackground {
|
||
#[default]
|
||
None,
|
||
Dark,
|
||
Light,
|
||
}
|
||
|
||
impl From<RawBlockBackground> for BlockBackground {
|
||
fn from(raw: RawBlockBackground) -> Self {
|
||
match raw {
|
||
RawBlockBackground::None => BlockBackground::None,
|
||
RawBlockBackground::Dark => BlockBackground::Dark,
|
||
RawBlockBackground::Light => BlockBackground::Light,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Raw → Runtime Conversion
|
||
// ============================================================================
|
||
|
||
impl From<RawAppearanceConfig> for AppearanceConfig {
|
||
fn from(raw: RawAppearanceConfig) -> Self {
|
||
Self {
|
||
animation: raw.animation.into(),
|
||
prompt: PromptViewConfig {
|
||
collapse_unfocused: raw.prompt.collapse_unfocused,
|
||
mouse_hover: raw.prompt.mouse_hover,
|
||
show_prefix: raw.prompt.show_prefix,
|
||
compact: false, // runtime-only, not persisted in TOML
|
||
},
|
||
scrollback: ScrollbackConfig {
|
||
layout: raw.scrollback.layout.into(),
|
||
scrollbar: raw.scrollback.scrollbar.into(),
|
||
scroll: raw.scrollback.scroll.into(),
|
||
blocks: raw.scrollback.blocks.into(),
|
||
display: ScrollbackDisplayConfig {
|
||
line_under_last_entry: raw.scrollback.display.line_under_last_entry,
|
||
collapsed_accent_char: raw
|
||
.scrollback
|
||
.display
|
||
.collapsed_accent_char
|
||
.unwrap_or_else(|| crate::glyphs::collapsed_accent().to_string()),
|
||
dim_accent: raw.scrollback.display.dim_accent.unwrap_or(0.5),
|
||
group_selection_split: raw
|
||
.scrollback
|
||
.display
|
||
.group_selection_split
|
||
.unwrap_or(true),
|
||
highlight_overlays_border: raw
|
||
.scrollback
|
||
.display
|
||
.highlight_overlays_border
|
||
.unwrap_or(false),
|
||
expandable_indicator: raw
|
||
.scrollback
|
||
.display
|
||
.expandable_indicator
|
||
.unwrap_or(true),
|
||
expandable_indicator_running: raw
|
||
.scrollback
|
||
.display
|
||
.expandable_indicator_running
|
||
.unwrap_or(true),
|
||
expandable_indicator_char: raw
|
||
.scrollback
|
||
.display
|
||
.expandable_indicator_char
|
||
.unwrap_or_else(|| "›".to_string()),
|
||
selection_buttons: raw.scrollback.display.selection_buttons.unwrap_or(false),
|
||
sticky_headers: raw.scrollback.display.sticky_headers.unwrap_or(true),
|
||
tab_width: raw.scrollback.display.tab_width.unwrap_or(4),
|
||
group_max_visible: raw.scrollback.display.group_max_visible.unwrap_or(10),
|
||
},
|
||
},
|
||
todo: TodoConfig {
|
||
badge_format: raw.todo.badge_format.into(),
|
||
},
|
||
turn_status: TurnStatusConfig::default(),
|
||
show_timestamps: true, // runtime-only, loaded from config.toml via persist
|
||
// Single source: UiConfig::SHOW_TIMELINE_DEFAULT (loaded from config.toml via persist).
|
||
show_timeline: UiConfig::SHOW_TIMELINE_DEFAULT,
|
||
disable_plugins: raw.disable_plugins,
|
||
show_plan_chip: raw.show_plan_chip,
|
||
alt_screen: raw.terminal.alt_screen.into(),
|
||
minimal: raw.terminal.minimal,
|
||
minimal_live_rows: raw.terminal.minimal_live_rows.unwrap_or(10),
|
||
minimal_max_commit_rows: raw.terminal.minimal_max_commit_rows.unwrap_or(2000),
|
||
minimal_collapse_thinking: raw.terminal.minimal_collapse_thinking,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<RawAnimationConfig> for AnimationConfig {
|
||
fn from(raw: RawAnimationConfig) -> Self {
|
||
Self {
|
||
fps: raw.fps.clamp(1, 60),
|
||
wave_rows: raw.wave_rows.max(1),
|
||
show_fps: raw.show_fps,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<RawFollowIndicator> for FollowIndicator {
|
||
fn from(raw: RawFollowIndicator) -> Self {
|
||
match raw {
|
||
RawFollowIndicator::None => Self::None,
|
||
RawFollowIndicator::Center => Self::Center,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<RawToolBullet> for ToolBullet {
|
||
fn from(raw: RawToolBullet) -> Self {
|
||
match raw {
|
||
RawToolBullet::None => Self::None,
|
||
RawToolBullet::Dot => Self::Dot,
|
||
RawToolBullet::SmallCircle => Self::SmallCircle,
|
||
RawToolBullet::Circle => Self::Circle,
|
||
RawToolBullet::SmallTriangle => Self::SmallTriangle,
|
||
RawToolBullet::Triangle => Self::Triangle,
|
||
RawToolBullet::Diamond => Self::Diamond,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<RawScrollConfig> for ScrollConfig {
|
||
fn from(raw: RawScrollConfig) -> Self {
|
||
Self {
|
||
margin: raw.margin,
|
||
min_page_fraction: raw.min_page_fraction.min(100),
|
||
follow_indicator: raw.follow_indicator.into(),
|
||
follow_auto_select: raw.follow_auto_select,
|
||
follow_by_overscroll: raw.follow_by_overscroll,
|
||
anchor_on_fold: raw.anchor_on_fold,
|
||
respect_manual_folds: raw.respect_manual_folds,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<RawLayoutConfig> for LayoutConfig {
|
||
fn from(raw: RawLayoutConfig) -> Self {
|
||
Self {
|
||
outer_vpad: raw.outer_vpad,
|
||
outer_hpad_left: raw.outer_hpad_left,
|
||
outer_hpad_right: raw.outer_hpad_right,
|
||
block_pad_left: raw.block_pad_left,
|
||
block_pad_right: raw.block_pad_right,
|
||
}
|
||
.validated()
|
||
}
|
||
}
|
||
|
||
impl From<RawScrollbarConfig> for ScrollbarConfig {
|
||
fn from(raw: RawScrollbarConfig) -> Self {
|
||
Self {
|
||
enabled: raw.enabled,
|
||
gap_left: raw.gap_left,
|
||
gap_right: raw.gap_right,
|
||
scrollbar_bg: raw.scrollbar_bg.to_option(),
|
||
scrollbar_fg: raw.scrollbar_fg.to_option(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<RawBlocksConfig> for BlocksConfig {
|
||
fn from(raw: RawBlocksConfig) -> Self {
|
||
Self {
|
||
edit: raw.edit.into(),
|
||
prompt: raw.prompt.into(),
|
||
thinking: raw.thinking.into(),
|
||
tool: raw.tool.into(),
|
||
list_dir: raw.list_dir.into(),
|
||
execute: raw.execute.into(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<RawToolConfig> for ToolConfig {
|
||
fn from(raw: RawToolConfig) -> Self {
|
||
Self {
|
||
muted_collapsed: raw.muted_collapsed,
|
||
dim_details: raw.dim_details,
|
||
bullet: raw.bullet.into(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<RawListDirConfig> for ListDirConfig {
|
||
fn from(raw: RawListDirConfig) -> Self {
|
||
Self {
|
||
terminal_bg: raw.terminal_bg,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<RawExecuteConfig> for ExecuteConfig {
|
||
fn from(raw: RawExecuteConfig) -> Self {
|
||
// Default accent is accent_running from theme — already quantized.
|
||
let default_accent = crate::theme::Theme::current().accent_running;
|
||
let accent = raw.running_accent.to_option().unwrap_or(default_accent);
|
||
Self {
|
||
first_lines: raw.first_lines.max(1),
|
||
last_lines: raw.last_lines.max(1),
|
||
accent_enabled: raw.accent_enabled,
|
||
running_accent: accent,
|
||
header_style: raw.header_style.into(),
|
||
muted_command_collapsed: raw.muted_command_collapsed,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<RawEditBlockConfig> for EditBlockConfig {
|
||
fn from(raw: RawEditBlockConfig) -> Self {
|
||
Self {
|
||
indent: raw.indent,
|
||
vpad: raw.vpad,
|
||
bg: raw.bg.into(),
|
||
accent_bg: raw.accent_bg,
|
||
accent: raw.accent.to_option(),
|
||
gutter_bg: raw.gutter_bg,
|
||
indent_bg: raw.indent_bg,
|
||
line_summary: raw.line_summary,
|
||
expanded_by_default: raw.expanded_by_default,
|
||
hunk_separator: raw.hunk_separator.unwrap_or_else(|| "…".to_string()),
|
||
dual_line_numbers: raw.dual_line_numbers,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<RawPromptConfig> for PromptConfig {
|
||
fn from(raw: RawPromptConfig) -> Self {
|
||
Self {
|
||
vpad: raw.vpad,
|
||
bg: raw.bg.into(),
|
||
accent_bg: raw.accent_bg,
|
||
min_lines: raw.min_lines,
|
||
show_prefix: raw.show_prefix,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<RawThinkingConfig> for ThinkingConfig {
|
||
fn from(raw: RawThinkingConfig) -> Self {
|
||
// Default accent is gray_dim from theme — already quantized.
|
||
let default_accent = crate::theme::Theme::current().gray_dim;
|
||
let accent = raw.accent.to_option().unwrap_or(default_accent);
|
||
// Convert 0-100 integer to 0.0-1.0 float
|
||
let bg_blend = (raw.bg_blend.min(100) as f32) / 100.0;
|
||
Self {
|
||
accent,
|
||
accent_enabled: raw.accent_enabled,
|
||
bg_blend,
|
||
truncated_lines: raw.truncated_lines.max(1),
|
||
animate: raw.animate,
|
||
header: raw.header,
|
||
header_bright: raw.header_bright,
|
||
body_dim_italic: false,
|
||
collapsed_expand_hint: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Color Parsing
|
||
// ============================================================================
|
||
|
||
/// An optional color that can be "none" or a color value.
|
||
/// This allows TOML to represent None values explicitly.
|
||
#[derive(Debug, Clone, PartialEq, Default)]
|
||
pub enum OptionalColor {
|
||
#[default]
|
||
None,
|
||
Some(Color),
|
||
}
|
||
|
||
impl OptionalColor {
|
||
/// Convert to `Option<Color>`, quantizing to the terminal's color level.
|
||
///
|
||
/// User-configured colors arrive as raw RGB from TOML. Unlike theme
|
||
/// colors (which are pre-quantized by [`Theme::current()`]), these
|
||
/// need quantization here to work correctly on 256-color terminals.
|
||
pub fn to_option(&self) -> Option<Color> {
|
||
match self {
|
||
OptionalColor::None => None,
|
||
OptionalColor::Some(c) => Some(crate::theme::quantize(*c)),
|
||
}
|
||
}
|
||
|
||
/// Convert to `Option<Color>` without quantization.
|
||
///
|
||
/// Returns the raw parsed color value. Used for serialization
|
||
/// round-trip tests and anywhere the original RGB is needed.
|
||
pub fn to_option_raw(&self) -> Option<Color> {
|
||
match self {
|
||
OptionalColor::None => None,
|
||
OptionalColor::Some(c) => Some(*c),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Serialize for OptionalColor {
|
||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||
match self {
|
||
OptionalColor::None => serializer.serialize_str("none"),
|
||
OptionalColor::Some(Color::Rgb(r, g, b)) => [*r, *g, *b].serialize(serializer),
|
||
OptionalColor::Some(Color::Indexed(n)) => {
|
||
// Convert indexed to RGB for serialization so the TOML round-trips.
|
||
let (r, g, b) = crate::render::color::indexed_to_rgb(*n);
|
||
[r, g, b].serialize(serializer)
|
||
}
|
||
OptionalColor::Some(_) => serializer.serialize_str("unknown"),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl<'de> Deserialize<'de> for OptionalColor {
|
||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||
let value = RawColorValue::deserialize(deserializer)?;
|
||
match value {
|
||
RawColorValue::Rgb([r, g, b]) => Ok(OptionalColor::Some(Color::Rgb(r, g, b))),
|
||
RawColorValue::Text(s) => {
|
||
let s = s.trim().to_lowercase();
|
||
if s == "none" || s == "null" {
|
||
Ok(OptionalColor::None)
|
||
} else {
|
||
parse_color_string(&s)
|
||
.map(OptionalColor::Some)
|
||
.map_err(serde::de::Error::custom)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(untagged)]
|
||
enum RawColorValue {
|
||
Rgb([u8; 3]),
|
||
Text(String),
|
||
}
|
||
|
||
fn parse_color_string(s: &str) -> Result<Color, String> {
|
||
let s = s.trim();
|
||
if let Some(hex) = s.strip_prefix('#') {
|
||
return parse_hex_color(hex);
|
||
}
|
||
lookup_named_color(s)
|
||
}
|
||
|
||
fn parse_hex_color(hex: &str) -> Result<Color, String> {
|
||
let hex = hex.trim_start_matches('#');
|
||
let (r, g, b) = match hex.len() {
|
||
3 => {
|
||
let r = u8::from_str_radix(&hex[0..1], 16).map_err(|e| e.to_string())? * 17;
|
||
let g = u8::from_str_radix(&hex[1..2], 16).map_err(|e| e.to_string())? * 17;
|
||
let b = u8::from_str_radix(&hex[2..3], 16).map_err(|e| e.to_string())? * 17;
|
||
(r, g, b)
|
||
}
|
||
6 => {
|
||
let r = u8::from_str_radix(&hex[0..2], 16).map_err(|e| e.to_string())?;
|
||
let g = u8::from_str_radix(&hex[2..4], 16).map_err(|e| e.to_string())?;
|
||
let b = u8::from_str_radix(&hex[4..6], 16).map_err(|e| e.to_string())?;
|
||
(r, g, b)
|
||
}
|
||
_ => return Err(format!("invalid hex color: #{hex}")),
|
||
};
|
||
Ok(Color::Rgb(r, g, b))
|
||
}
|
||
|
||
fn lookup_named_color(name: &str) -> Result<Color, String> {
|
||
// Named colors use the GrokNight RGB palette. They are quantized via
|
||
// `parse_color_string` → `quantize()` to match the terminal's capabilities.
|
||
let color = match name.to_uppercase().as_str() {
|
||
// Background colors
|
||
"BG" | "BG_BASE" => Color::Rgb(20, 20, 20), // #141414
|
||
"BG_LIGHT" | "BG_HIGHLIGHT" => Color::Rgb(30, 30, 30), // #1e1e1e
|
||
"BG_DARK" => Color::Rgb(17, 17, 17), // #111111
|
||
"BG_TERMINAL" | "BG_NIGHT" => Color::Rgb(10, 10, 10), // #0a0a0a
|
||
"BG_VISUAL" => Color::Rgb(30, 32, 45), // blue-tinted selection
|
||
"BG_SEARCH" => Color::Rgb(48, 48, 52), // #303034
|
||
|
||
// Accent colors (TokyoNight Night)
|
||
"BLUE" => Color::Rgb(77, 121, 255), // #4D79FF
|
||
"BLUE0" => Color::Rgb(61, 89, 161), // #3d59a1
|
||
"BLUE1" => Color::Rgb(42, 195, 222), // #2ac3de
|
||
"BLUE2" => Color::Rgb(13, 185, 215), // #0db9d7
|
||
"BLUE5" => Color::Rgb(137, 221, 255), // #89ddff
|
||
"BLUE6" => Color::Rgb(180, 249, 248), // #b4f9f8
|
||
"BLUE7" => Color::Rgb(57, 75, 112), // #394b70
|
||
"CYAN" => Color::Rgb(125, 207, 255), // #7dcfff
|
||
"GREEN" => Color::Rgb(36, 196, 116), // #24C474
|
||
"GREEN1" => Color::Rgb(115, 218, 202), // #73daca
|
||
"GREEN2" => Color::Rgb(65, 166, 181), // #41a6b5
|
||
"YELLOW" => Color::Rgb(255, 219, 141), // #FFDB8D
|
||
"ORANGE" => Color::Rgb(255, 158, 100), // #ff9e64
|
||
"RED" => Color::Rgb(248, 114, 122), // #F8727A
|
||
"RED1" => Color::Rgb(219, 75, 75), // #db4b4b
|
||
"MAGENTA" => Color::Rgb(187, 154, 247), // #bb9af7
|
||
"PURPLE" => Color::Rgb(131, 113, 211), // #8371D3
|
||
"MAGENTA2" => Color::Rgb(255, 0, 124), // #ff007c
|
||
"TEAL" | "HINT" => Color::Rgb(26, 188, 156), // #1abc9c
|
||
|
||
// Text colors
|
||
"FG" | "TEXT" | "TEXT_PRIMARY" => Color::Rgb(243, 243, 243), // #f3f3f3
|
||
"FG_DARK" | "TEXT_SECONDARY" => Color::Rgb(200, 200, 200), // #c8c8c8
|
||
"FG_GUTTER" => Color::Rgb(65, 65, 65), // #414141
|
||
"COMMENT" | "MUTED" | "TEXT_MUTED" => Color::Rgb(98, 98, 98), // #626262
|
||
"DARK3" => Color::Rgb(90, 90, 90), // #5a5a5a
|
||
"DARK5" | "TOOL" => Color::Rgb(120, 120, 120), // #787878
|
||
|
||
// Semantic colors
|
||
"ERROR" => Color::Rgb(247, 118, 142), // RED
|
||
"SUCCESS" => Color::Rgb(158, 206, 106), // GREEN
|
||
"WARNING" => Color::Rgb(224, 175, 104), // YELLOW
|
||
"INFO" => Color::Rgb(125, 207, 255), // CYAN
|
||
|
||
// Basic colors
|
||
"BLACK" => Color::Black,
|
||
"WHITE" => Color::White,
|
||
"GRAY" | "GREY" => Color::Gray,
|
||
|
||
_ => return Err(format!("unknown color name: {name}")),
|
||
};
|
||
Ok(color)
|
||
}
|
||
|
||
// ============================================================================
|
||
// TOML Generation with Comments
|
||
// ============================================================================
|
||
|
||
impl RawAppearanceConfig {
|
||
pub fn to_toml_with_comments() -> String {
|
||
let mut config = Self::default();
|
||
// Template-only materialization: these default to None (the shell's
|
||
// `[ui] collapsed_edit_blocks` flag decides), which the serializer
|
||
// would omit entirely. Show the flag-off shape as commented lines so
|
||
// the keys stay discoverable; commenting-out below keeps them inert.
|
||
config.scrollback.blocks.edit.expanded_by_default = Some(true);
|
||
config.scrollback.blocks.edit.line_summary = Some(false);
|
||
let toml_str = toml_edit::ser::to_string_pretty(&config).expect("serialize default");
|
||
let mut doc: DocumentMut = toml_str.parse().expect("parse toml");
|
||
|
||
let pager_path = crate::util::display_user_grok_path("pager.toml");
|
||
let header = format!(
|
||
"\
|
||
# Grok Pager Appearance Configuration ({pager_path})
|
||
# Every value below is a commented-out built-in default: uncomment a line and
|
||
# save to override it. Values left commented track future default changes.
|
||
# Delete the file to regenerate this template.
|
||
#
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# TOKYO NIGHT STORM COLOR PALETTE
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
#
|
||
# Colors can be specified as:
|
||
# - Named color: \"BLUE\", \"cyan\", \"Comment\" (case-insensitive)
|
||
# - Hex string: \"#7aa2f7\" or \"#f7f\" (3 or 6 digits)
|
||
# - RGB array: [122, 162, 247]
|
||
#
|
||
# Background colors:
|
||
# BG, BG_BASE #141414 (neutral background)
|
||
# BG_LIGHT #1c1c1c (highlighted background)
|
||
# BG_DARK #111111 (darker background)
|
||
# BG_TERMINAL #0e0e0e (terminal background)
|
||
# BG_VISUAL #262628 (visual selection background)
|
||
# BG_SEARCH #303034 (search highlight background)
|
||
#
|
||
# Primary colors:
|
||
# BLUE #7aa2f7
|
||
# BLUE0 #3d59a1 (dark blue, used for search/visual)
|
||
# BLUE1 #2ac3de (bright cyan-blue)
|
||
# BLUE2 #0db9d7 (teal-blue)
|
||
# BLUE5 #89ddff (light cyan)
|
||
# BLUE6 #b4f9f8 (pale cyan)
|
||
# BLUE7 #394b70 (dark muted blue)
|
||
# CYAN #7dcfff
|
||
# GREEN #9ece6a
|
||
# GREEN1 #73daca (teal-green)
|
||
# GREEN2 #41a6b5 (dark teal)
|
||
# YELLOW #e0af68
|
||
# ORANGE #ff9e64
|
||
# RED #f7768e
|
||
# RED1 #db4b4b (dark red)
|
||
# MAGENTA #bb9af7
|
||
# PURPLE #9d7cd8
|
||
# MAGENTA2 #ff007c (hot pink)
|
||
# TEAL, HINT #1abc9c
|
||
#
|
||
# Text colors:
|
||
# FG, TEXT #c8c8c8 (primary text)
|
||
# FG_DARK #b2b2b2 (secondary text)
|
||
# FG_GUTTER #414141 (line number gutter)
|
||
# COMMENT, MUTED #5f5f5f (muted/comment text)
|
||
# DARK3 #5a5a5a (medium gray)
|
||
# DARK5, TOOL #787878 (tool accent, system prompt)
|
||
#
|
||
# Semantic colors:
|
||
# ERROR #f7768e (same as RED)
|
||
# SUCCESS #9ece6a (same as GREEN)
|
||
# WARNING #e0af68 (same as YELLOW)
|
||
# INFO #7dcfff (same as CYAN)
|
||
#
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
"
|
||
);
|
||
|
||
if let Some(terminal) = doc.get_mut("terminal").and_then(Item::as_table_mut) {
|
||
annotate_table::<RawTerminalConfig>(terminal);
|
||
}
|
||
|
||
if let Some(todo) = doc.get_mut("todo").and_then(Item::as_table_mut) {
|
||
annotate_table::<RawTodoConfig>(todo);
|
||
}
|
||
|
||
if let Some(animation) = doc.get_mut("animation").and_then(Item::as_table_mut) {
|
||
annotate_table::<RawAnimationConfig>(animation);
|
||
}
|
||
|
||
if let Some(prompt) = doc.get_mut("prompt").and_then(Item::as_table_mut) {
|
||
annotate_table::<RawPromptViewConfig>(prompt);
|
||
}
|
||
|
||
if let Some(scrollback) = doc.get_mut("scrollback").and_then(Item::as_table_mut) {
|
||
if let Some(display) = scrollback.get_mut("display").and_then(Item::as_table_mut) {
|
||
annotate_table::<RawScrollbackDisplayConfig>(display);
|
||
}
|
||
if let Some(layout) = scrollback.get_mut("layout").and_then(Item::as_table_mut) {
|
||
annotate_table::<RawLayoutConfig>(layout);
|
||
}
|
||
if let Some(scrollbar) = scrollback.get_mut("scrollbar").and_then(Item::as_table_mut) {
|
||
annotate_table::<RawScrollbarConfig>(scrollbar);
|
||
}
|
||
if let Some(scroll) = scrollback.get_mut("scroll").and_then(Item::as_table_mut) {
|
||
annotate_table::<RawScrollConfig>(scroll);
|
||
}
|
||
if let Some(blocks) = scrollback.get_mut("blocks").and_then(Item::as_table_mut) {
|
||
if let Some(edit) = blocks.get_mut("edit").and_then(Item::as_table_mut) {
|
||
annotate_table::<RawEditBlockConfig>(edit);
|
||
}
|
||
if let Some(prompt) = blocks.get_mut("prompt").and_then(Item::as_table_mut) {
|
||
annotate_table::<RawPromptConfig>(prompt);
|
||
}
|
||
if let Some(thinking) = blocks.get_mut("thinking").and_then(Item::as_table_mut) {
|
||
annotate_table::<RawThinkingConfig>(thinking);
|
||
}
|
||
if let Some(tool) = blocks.get_mut("tool").and_then(Item::as_table_mut) {
|
||
annotate_table::<RawToolConfig>(tool);
|
||
}
|
||
if let Some(list_dir) = blocks.get_mut("list_dir").and_then(Item::as_table_mut) {
|
||
annotate_table::<RawListDirConfig>(list_dir);
|
||
}
|
||
if let Some(execute) = blocks.get_mut("execute").and_then(Item::as_table_mut) {
|
||
annotate_table::<RawExecuteConfig>(execute);
|
||
}
|
||
}
|
||
}
|
||
|
||
format!("{header}{}", comment_out_values(&doc.to_string()))
|
||
}
|
||
}
|
||
|
||
/// Comment out every key-value line, keeping section headers and comments.
|
||
/// The generated template documents defaults without pinning them, so a
|
||
/// future built-in default change reaches installs holding an old file
|
||
/// (active values would freeze the defaults of the day forever).
|
||
fn comment_out_values(toml: &str) -> String {
|
||
let mut out = String::with_capacity(toml.len() + 256);
|
||
for line in toml.lines() {
|
||
let trimmed = line.trim_start();
|
||
if !(trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('[')) {
|
||
out.push_str("# ");
|
||
}
|
||
out.push_str(line);
|
||
out.push('\n');
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Serializes the pager.toml read-modify-write so two rapid settings
|
||
/// toggles can't interleave and clobber each other (mirrors the shell's
|
||
/// `save_config` `SAVE_LOCK`).
|
||
static PAGER_TOML_SAVE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||
|
||
pub fn persist_respect_manual_folds(enabled: bool) -> std::io::Result<()> {
|
||
use std::io::{Error, ErrorKind};
|
||
|
||
if xai_grok_config::user_grok_home().is_none() {
|
||
return Err(Error::new(
|
||
ErrorKind::NotFound,
|
||
"no user grok home resolved; refusing to write a cwd-relative pager.toml \
|
||
that startup would never read",
|
||
));
|
||
}
|
||
let _guard = PAGER_TOML_SAVE_LOCK
|
||
.lock()
|
||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||
|
||
let path = crate::util::pager_toml_path();
|
||
let content = match std::fs::read_to_string(&path) {
|
||
Ok(c) => c,
|
||
Err(e) if e.kind() == ErrorKind::NotFound => String::new(),
|
||
Err(e) => return Err(e),
|
||
};
|
||
let updated = upsert_respect_manual_folds(&content, enabled)
|
||
.map_err(|e| Error::new(ErrorKind::InvalidData, e))?;
|
||
if let Some(dir) = path.parent() {
|
||
std::fs::create_dir_all(dir)?;
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
let prior_mode: Option<u32> = std::fs::metadata(&path).ok().map(|m| {
|
||
use std::os::unix::fs::PermissionsExt;
|
||
m.permissions().mode()
|
||
});
|
||
|
||
let suffix = {
|
||
let nanos = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_nanos())
|
||
.unwrap_or(0);
|
||
format!("toml.tmp.{}.{}", std::process::id(), nanos)
|
||
};
|
||
let tmp = path.with_extension(suffix);
|
||
std::fs::write(&tmp, updated)?;
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
if let Some(mode) = prior_mode {
|
||
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode));
|
||
}
|
||
}
|
||
std::fs::rename(&tmp, &path)
|
||
}
|
||
|
||
fn upsert_respect_manual_folds(content: &str, enabled: bool) -> Result<String, String> {
|
||
let mut doc: DocumentMut = content
|
||
.parse()
|
||
.map_err(|e: toml_edit::TomlError| e.to_string())?;
|
||
let scrollback = doc
|
||
.entry("scrollback")
|
||
.or_insert_with(implicit_table)
|
||
.as_table_mut()
|
||
.ok_or_else(|| "pager.toml `scrollback` is not a table".to_string())?;
|
||
let scroll = scrollback
|
||
.entry("scroll")
|
||
.or_insert(toml_edit::Item::Table(toml_edit::Table::new()))
|
||
.as_table_mut()
|
||
.ok_or_else(|| "pager.toml `scrollback.scroll` is not a table".to_string())?;
|
||
scroll.insert("respect_manual_folds", toml_edit::value(enabled));
|
||
Ok(doc.to_string())
|
||
}
|
||
|
||
fn implicit_table() -> Item {
|
||
let mut table = toml_edit::Table::new();
|
||
table.set_implicit(true);
|
||
Item::Table(table)
|
||
}
|
||
|
||
fn annotate_table<T: DocumentedFields>(table: &mut toml_edit::Table) {
|
||
for (mut key, _value) in table.iter_mut() {
|
||
let field_name = key.get();
|
||
if let Ok(docs) = T::get_field_docs(field_name) {
|
||
let comment: String = docs
|
||
.lines()
|
||
.map(|l| {
|
||
if l.is_empty() {
|
||
"#\n".to_string()
|
||
} else {
|
||
format!("# {l}\n")
|
||
}
|
||
})
|
||
.collect();
|
||
let decor = key.leaf_decor_mut();
|
||
let prefix = decor.prefix().and_then(RawString::as_str).unwrap_or("");
|
||
decor.set_prefix(format!("{prefix}{comment}"));
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Tests
|
||
// ============================================================================
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_parse_hex_color() {
|
||
assert_eq!(parse_hex_color("ff8800"), Ok(Color::Rgb(255, 136, 0)));
|
||
assert_eq!(parse_hex_color("f80"), Ok(Color::Rgb(255, 136, 0)));
|
||
assert_eq!(parse_hex_color("000"), Ok(Color::Rgb(0, 0, 0)));
|
||
assert_eq!(parse_hex_color("fff"), Ok(Color::Rgb(255, 255, 255)));
|
||
}
|
||
|
||
#[test]
|
||
fn test_parse_named_color() {
|
||
assert_eq!(lookup_named_color("BLUE"), Ok(Color::Rgb(77, 121, 255)));
|
||
assert_eq!(lookup_named_color("blue"), Ok(Color::Rgb(77, 121, 255)));
|
||
assert!(lookup_named_color("NOTACOLOR").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn test_deserialize_rgb_array() {
|
||
let toml = r#"color = [255, 128, 0]"#;
|
||
#[derive(Deserialize)]
|
||
struct Test {
|
||
color: OptionalColor,
|
||
}
|
||
let t: Test = toml::from_str(toml).unwrap();
|
||
assert_eq!(t.color.to_option_raw(), Some(Color::Rgb(255, 128, 0)));
|
||
}
|
||
|
||
#[test]
|
||
fn test_deserialize_hex_string() {
|
||
let toml = r##"color = "#ff8800""##;
|
||
#[derive(Deserialize)]
|
||
struct Test {
|
||
color: OptionalColor,
|
||
}
|
||
let t: Test = toml::from_str(toml).unwrap();
|
||
assert_eq!(t.color.to_option_raw(), Some(Color::Rgb(255, 136, 0)));
|
||
}
|
||
|
||
#[test]
|
||
fn test_deserialize_named_color() {
|
||
let toml = r#"color = "BLUE""#;
|
||
#[derive(Deserialize)]
|
||
struct Test {
|
||
color: OptionalColor,
|
||
}
|
||
let t: Test = toml::from_str(toml).unwrap();
|
||
assert_eq!(t.color.to_option_raw(), Some(Color::Rgb(77, 121, 255)));
|
||
}
|
||
|
||
#[test]
|
||
fn test_deserialize_none_color() {
|
||
let toml = r#"color = "none""#;
|
||
#[derive(Deserialize)]
|
||
struct Test {
|
||
color: OptionalColor,
|
||
}
|
||
let t: Test = toml::from_str(toml).unwrap();
|
||
assert_eq!(t.color.to_option(), None);
|
||
}
|
||
|
||
#[test]
|
||
fn test_deserialize_full_config() {
|
||
let toml = r#"
|
||
[scrollback.blocks.edit]
|
||
indent = false
|
||
vpad = true
|
||
bg = "dark"
|
||
accent_bg = true
|
||
accent = "CYAN"
|
||
gutter_bg = true
|
||
"#;
|
||
let raw: RawAppearanceConfig = toml::from_str(toml).unwrap();
|
||
let cfg: AppearanceConfig = raw.into();
|
||
assert!(!cfg.scrollback.blocks.edit.indent);
|
||
assert!(cfg.scrollback.blocks.edit.vpad);
|
||
assert_eq!(cfg.scrollback.blocks.edit.bg, BlockBackground::Dark);
|
||
assert!(cfg.scrollback.blocks.edit.accent_bg);
|
||
// CYAN from lookup_named_color — quantized by OptionalColor::to_option().
|
||
// In non-TTY test environments, quantize() may downgrade to ANSI 16,
|
||
// so compare against the quantized value.
|
||
assert!(cfg.scrollback.blocks.edit.accent.is_some());
|
||
assert!(cfg.scrollback.blocks.edit.gutter_bg);
|
||
}
|
||
|
||
#[test]
|
||
fn test_default_config() {
|
||
let cfg = AppearanceConfig::default();
|
||
assert!(cfg.scrollback.blocks.edit.indent);
|
||
assert!(!cfg.scrollback.blocks.edit.vpad);
|
||
assert_eq!(cfg.scrollback.blocks.edit.bg, BlockBackground::None);
|
||
}
|
||
|
||
/// Legacy configs still contain the removed `invert` key (it was written
|
||
/// by old generated `pager.toml`s); parsing must keep ignoring it.
|
||
#[test]
|
||
fn removed_prompt_invert_key_is_ignored() {
|
||
let raw: RawAppearanceConfig =
|
||
toml::from_str("[scrollback.blocks.prompt]\ninvert = true\nvpad = false").unwrap();
|
||
let cfg: AppearanceConfig = raw.into();
|
||
assert!(!cfg.scrollback.blocks.prompt.vpad);
|
||
}
|
||
|
||
#[test]
|
||
fn test_respect_manual_folds_defaults_off_and_round_trips() {
|
||
let cfg = AppearanceConfig::default();
|
||
assert!(!cfg.scrollback.scroll.respect_manual_folds);
|
||
|
||
let raw: RawAppearanceConfig =
|
||
toml::from_str("[scrollback.scroll]\nrespect_manual_folds = true").unwrap();
|
||
let cfg: AppearanceConfig = raw.into();
|
||
assert!(cfg.scrollback.scroll.respect_manual_folds);
|
||
}
|
||
|
||
#[test]
|
||
fn test_upsert_respect_manual_folds_writes_key_and_preserves_siblings() {
|
||
let updated = upsert_respect_manual_folds("", true).unwrap();
|
||
assert!(
|
||
updated.contains("[scrollback.scroll]"),
|
||
"missing section must render in section form, not as inline tables:\n{updated}"
|
||
);
|
||
let raw: RawAppearanceConfig = toml::from_str(&updated).unwrap();
|
||
let cfg: AppearanceConfig = raw.into();
|
||
assert!(
|
||
cfg.scrollback.scroll.respect_manual_folds,
|
||
"empty pager.toml: upserted key must parse back as true:\n{updated}"
|
||
);
|
||
|
||
let existing = "[scrollback.scroll]\nanchor_on_fold = false\nrespect_manual_folds = true\n";
|
||
let updated = upsert_respect_manual_folds(existing, false).unwrap();
|
||
let raw: RawAppearanceConfig = toml::from_str(&updated).unwrap();
|
||
let cfg: AppearanceConfig = raw.into();
|
||
assert!(
|
||
!cfg.scrollback.scroll.respect_manual_folds,
|
||
"key updated in place"
|
||
);
|
||
assert!(
|
||
!cfg.scrollback.scroll.anchor_on_fold,
|
||
"sibling keys must be preserved:\n{updated}"
|
||
);
|
||
|
||
assert!(
|
||
upsert_respect_manual_folds("scrollback = 5", true).is_err(),
|
||
"non-table `scrollback` must be a hard error, not a panic"
|
||
);
|
||
assert!(
|
||
upsert_respect_manual_folds("[scrollback]\nscroll = 5", true).is_err(),
|
||
"non-table `scrollback.scroll` must be a hard error, not a panic"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_to_toml_with_comments() {
|
||
let toml = RawAppearanceConfig::to_toml_with_comments();
|
||
// Check scrollback sections — check for key names rather than section
|
||
// headers since toml_edit may format subtables differently when the
|
||
// parent table has inline keys (like line_under_last_entry).
|
||
assert!(
|
||
toml.contains("margin = "),
|
||
"Missing scroll margin in:\n{toml}"
|
||
);
|
||
assert!(
|
||
toml.contains("min_page_fraction = "),
|
||
"Missing scroll min_page_fraction in:\n{toml}"
|
||
);
|
||
assert!(
|
||
toml.contains("follow_indicator = "),
|
||
"Missing follow_indicator in:\n{toml}"
|
||
);
|
||
assert!(
|
||
toml.contains("follow_auto_select = "),
|
||
"Missing follow_auto_select in:\n{toml}"
|
||
);
|
||
assert!(
|
||
toml.contains("follow_by_overscroll = "),
|
||
"Missing follow_by_overscroll in:\n{toml}"
|
||
);
|
||
assert!(
|
||
toml.contains("respect_manual_folds = "),
|
||
"Missing respect_manual_folds in:\n{toml}"
|
||
);
|
||
assert!(
|
||
toml.contains("# Opt-in: keep manually folded blocks as-is"),
|
||
"Missing respect_manual_folds comment in:\n{toml}"
|
||
);
|
||
assert!(
|
||
toml.contains("line_under_last_entry"),
|
||
"Missing line_under_last_entry in:\n{toml}"
|
||
);
|
||
// Check tool bullet fields
|
||
assert!(
|
||
toml.contains("bullet = "),
|
||
"Missing tool bullet in:\n{toml}"
|
||
);
|
||
// Note: bullet_color and bullet_accent removed in scrollback-v2 refactor.
|
||
assert!(
|
||
toml.contains("outer_vpad = "),
|
||
"Missing layout outer_vpad in:\n{toml}"
|
||
);
|
||
assert!(
|
||
toml.contains("enabled = "),
|
||
"Missing scrollbar enabled in:\n{toml}"
|
||
);
|
||
// Check animation section
|
||
assert!(
|
||
toml.contains("[animation]"),
|
||
"Missing [animation] section in:\n{toml}"
|
||
);
|
||
assert!(toml.contains("fps = "), "Missing animation fps in:\n{toml}");
|
||
assert!(
|
||
toml.contains("wave_rows = "),
|
||
"Missing animation wave_rows in:\n{toml}"
|
||
);
|
||
// Check edit block comments
|
||
assert!(toml.contains("# Whether to apply 2-char indent"));
|
||
assert!(toml.contains("indent = true"));
|
||
// Check prompt block comments
|
||
assert!(
|
||
toml.contains("# Whether to apply vertical padding"),
|
||
"Missing prompt vpad comment in:\n{toml}"
|
||
);
|
||
assert!(
|
||
toml.contains("# Minimum content lines to show"),
|
||
"Missing prompt min_lines comment in:\n{toml}"
|
||
);
|
||
// Check prompt view section
|
||
assert!(
|
||
toml.contains("[prompt]"),
|
||
"Missing [prompt] section in:\n{toml}"
|
||
);
|
||
assert!(
|
||
toml.contains("collapse_unfocused = "),
|
||
"Missing collapse_unfocused in:\n{toml}"
|
||
);
|
||
assert!(
|
||
toml.contains("mouse_hover = "),
|
||
"Missing mouse_hover in:\n{toml}"
|
||
);
|
||
// Check blocks are under scrollback
|
||
assert!(
|
||
toml.contains("[scrollback.blocks.edit]"),
|
||
"Missing [scrollback.blocks.edit] section in:\n{toml}"
|
||
);
|
||
assert!(
|
||
toml.contains("[scrollback.blocks.execute]"),
|
||
"Missing [scrollback.blocks.execute] section in:\n{toml}"
|
||
);
|
||
// Check execute block has running_accent
|
||
assert!(
|
||
toml.contains("running_accent = "),
|
||
"Missing execute running_accent in:\n{toml}"
|
||
);
|
||
// Ensure old top-level sections are gone
|
||
assert!(
|
||
!toml.contains("\n[scroll]\n"),
|
||
"Old [scroll] section should not exist:\n{toml}"
|
||
);
|
||
assert!(
|
||
!toml.contains("[prompt_input]"),
|
||
"Old [prompt_input] section should not exist:\n{toml}"
|
||
);
|
||
assert!(
|
||
!toml.contains("\n[mouse]\n"),
|
||
"Old [mouse] section should not exist:\n{toml}"
|
||
);
|
||
assert!(
|
||
!toml.contains("\n[blocks."),
|
||
"Old top-level [blocks.*] should not exist:\n{toml}"
|
||
);
|
||
}
|
||
|
||
/// The generated template must be inert: parsing it (and an empty file)
|
||
/// yields exactly the built-in defaults, so old dev-generated files can
|
||
/// never pin a superseded default again.
|
||
#[test]
|
||
fn template_parses_to_builtin_defaults() {
|
||
let serialize = |cfg: &RawAppearanceConfig| toml_edit::ser::to_string_pretty(cfg).unwrap();
|
||
let defaults = serialize(&RawAppearanceConfig::default());
|
||
|
||
let template = RawAppearanceConfig::to_toml_with_comments();
|
||
let parsed: RawAppearanceConfig = toml::from_str(&template).expect("template must parse");
|
||
assert_eq!(
|
||
serialize(&parsed),
|
||
defaults,
|
||
"inert template must round-trip to built-in defaults"
|
||
);
|
||
|
||
let empty: RawAppearanceConfig = toml::from_str("").expect("empty config must parse");
|
||
assert_eq!(serialize(&empty), defaults);
|
||
}
|
||
|
||
/// Every value line in the template is commented out; only section
|
||
/// headers (empty tables) and comments are active.
|
||
#[test]
|
||
fn template_has_no_active_value_lines() {
|
||
let template = RawAppearanceConfig::to_toml_with_comments();
|
||
for line in template.lines() {
|
||
let trimmed = line.trim_start();
|
||
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('[') {
|
||
continue;
|
||
}
|
||
panic!("active value line in generated template: {line:?}");
|
||
}
|
||
}
|
||
|
||
/// The single policy point pairing the pager.toml shape keys with the
|
||
/// shell-owned `collapsed_edit_blocks` flag: unset keys follow the flag
|
||
/// (on = collapsed one-liner with diffstat, off = legacy expanded diff
|
||
/// without it); explicit values pin the shape in both directions.
|
||
#[test]
|
||
fn effective_edit_shape_follows_flag_unless_pinned() {
|
||
let unset = EditBlockConfig::default();
|
||
assert!(unset.effective_expanded(false), "flag off: expanded");
|
||
assert!(
|
||
!unset.effective_line_summary(false),
|
||
"flag off: no diffstat"
|
||
);
|
||
assert!(!unset.effective_expanded(true), "flag on: collapsed");
|
||
assert!(unset.effective_line_summary(true), "flag on: diffstat");
|
||
|
||
let pinned = EditBlockConfig {
|
||
expanded_by_default: Some(true),
|
||
line_summary: Some(true),
|
||
..EditBlockConfig::default()
|
||
};
|
||
assert!(
|
||
pinned.effective_expanded(true),
|
||
"explicit expanded beats the flag"
|
||
);
|
||
assert!(
|
||
pinned.effective_line_summary(false),
|
||
"explicit diffstat beats the flag"
|
||
);
|
||
let pinned = EditBlockConfig {
|
||
expanded_by_default: Some(false),
|
||
line_summary: Some(false),
|
||
..EditBlockConfig::default()
|
||
};
|
||
assert!(
|
||
!pinned.effective_expanded(false),
|
||
"explicit collapse beats the flag"
|
||
);
|
||
assert!(
|
||
!pinned.effective_line_summary(true),
|
||
"explicit no-diffstat beats the flag"
|
||
);
|
||
}
|
||
|
||
/// The two flag-deferred edit keys default to `None` (omitted by the
|
||
/// serializer), but the template must still document them as commented
|
||
/// lines showing the flag-off shape.
|
||
#[test]
|
||
fn template_documents_flag_deferred_edit_keys() {
|
||
let template = RawAppearanceConfig::to_toml_with_comments();
|
||
assert!(
|
||
template.contains("# expanded_by_default = true"),
|
||
"expanded_by_default missing from template:\n{template}"
|
||
);
|
||
assert!(
|
||
template.contains("# line_summary = false"),
|
||
"line_summary missing from template:\n{template}"
|
||
);
|
||
assert!(
|
||
template.contains("collapsed_edit_blocks"),
|
||
"doc comments must point at the [ui] flag:\n{template}"
|
||
);
|
||
}
|
||
|
||
/// The `/settings` writer must insert an ACTIVE key into the inert
|
||
/// template (whose keys are all comments) rather than edit a commented
|
||
/// line or fail.
|
||
#[test]
|
||
fn upsert_into_inert_template_inserts_active_key() {
|
||
let template = RawAppearanceConfig::to_toml_with_comments();
|
||
let updated = upsert_respect_manual_folds(&template, true).expect("upsert");
|
||
let parsed: RawAppearanceConfig =
|
||
toml::from_str(&updated).expect("updated template must parse");
|
||
assert!(
|
||
parsed.scrollback.scroll.respect_manual_folds,
|
||
"inserted key must be active:\n{updated}"
|
||
);
|
||
}
|
||
|
||
// ── Terminal config (alt_screen) parsing ─────────────────────
|
||
|
||
#[test]
|
||
fn terminal_alt_screen_auto_default() {
|
||
let raw: RawAppearanceConfig = toml::from_str("").unwrap();
|
||
assert_eq!(raw.terminal.alt_screen, RawAltScreenMode::Auto);
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_alt_screen_never() {
|
||
let raw: RawAppearanceConfig =
|
||
toml::from_str("[terminal]\nalt_screen = \"never\"").unwrap();
|
||
assert_eq!(raw.terminal.alt_screen, RawAltScreenMode::Never);
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_alt_screen_always() {
|
||
let raw: RawAppearanceConfig =
|
||
toml::from_str("[terminal]\nalt_screen = \"always\"").unwrap();
|
||
assert_eq!(raw.terminal.alt_screen, RawAltScreenMode::Always);
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_alt_screen_auto_explicit() {
|
||
let raw: RawAppearanceConfig = toml::from_str("[terminal]\nalt_screen = \"auto\"").unwrap();
|
||
assert_eq!(raw.terminal.alt_screen, RawAltScreenMode::Auto);
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_config_to_runtime_conversion() {
|
||
use crate::terminal::AltScreenMode;
|
||
|
||
let raw = RawAltScreenMode::Auto;
|
||
assert_eq!(AltScreenMode::from(raw), AltScreenMode::Auto);
|
||
|
||
let raw = RawAltScreenMode::Always;
|
||
assert_eq!(AltScreenMode::from(raw), AltScreenMode::Always);
|
||
|
||
let raw = RawAltScreenMode::Never;
|
||
assert_eq!(AltScreenMode::from(raw), AltScreenMode::Never);
|
||
}
|
||
|
||
#[test]
|
||
fn terminal_section_appears_in_generated_toml() {
|
||
let toml = RawAppearanceConfig::to_toml_with_comments();
|
||
assert!(
|
||
toml.contains("alt_screen = "),
|
||
"Missing alt_screen in generated config:\n{toml}"
|
||
);
|
||
}
|
||
|
||
/// A config written before the key existed must still parse and keep K9.
|
||
#[test]
|
||
fn minimal_collapse_thinking_defaults_off_and_old_configs_parse() {
|
||
let empty: RawAppearanceConfig = toml::from_str("").expect("empty config must parse");
|
||
assert!(!empty.terminal.minimal_collapse_thinking);
|
||
assert!(!AppearanceConfig::from(empty).minimal_collapse_thinking);
|
||
|
||
let legacy: RawAppearanceConfig =
|
||
toml::from_str("[terminal]\nminimal = true\nminimal_live_rows = 12\n")
|
||
.expect("legacy config must parse");
|
||
let cfg: AppearanceConfig = legacy.into();
|
||
assert!(cfg.minimal);
|
||
assert_eq!(cfg.minimal_live_rows, 12);
|
||
assert!(
|
||
!cfg.minimal_collapse_thinking,
|
||
"a config written before the key existed must keep the K9 default"
|
||
);
|
||
|
||
assert!(!AppearanceConfig::default().minimal_collapse_thinking);
|
||
}
|
||
|
||
#[test]
|
||
fn minimal_collapse_thinking_opt_in_parses() {
|
||
let raw: RawAppearanceConfig =
|
||
toml::from_str("[terminal]\nminimal_collapse_thinking = true\n").unwrap();
|
||
assert!(AppearanceConfig::from(raw).minimal_collapse_thinking);
|
||
}
|
||
|
||
/// The reasoning-legibility toggles must stay un-settable from pager.toml.
|
||
#[test]
|
||
fn thinking_body_treatment_is_off_by_default_and_not_a_toml_key() {
|
||
let cfg = AppearanceConfig::default();
|
||
assert!(!cfg.scrollback.blocks.thinking.body_dim_italic);
|
||
assert!(!cfg.scrollback.blocks.thinking.collapsed_expand_hint);
|
||
|
||
let template = RawAppearanceConfig::to_toml_with_comments();
|
||
assert!(!template.contains("body_dim_italic"));
|
||
assert!(!template.contains("collapsed_expand_hint"));
|
||
}
|
||
}
|