Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
56
crates/codegen/xai-grok-shared/Cargo.toml
Normal file
56
crates/codegen/xai-grok-shared/Cargo.toml
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
[package]
|
||||
license = "Apache-2.0"
|
||||
name = "xai-grok-shared"
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
agent-client-protocol = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
dirs = "6"
|
||||
dunce = { workspace = true }
|
||||
image = { workspace = true, features = ["png", "jpeg", "gif", "webp"] }
|
||||
parking_lot = { workspace = true }
|
||||
prod-mc-cli-chat-proxy-types = { path = "../../../prod/mc/cli-chat-proxy-types" }
|
||||
regex = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true, features = ["preserve_order"] }
|
||||
tempfile = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
urlencoding = { workspace = true }
|
||||
xai-grok-config-types = { workspace = true }
|
||||
xai-grok-models = { workspace = true }
|
||||
xai-grok-tools = { workspace = true }
|
||||
xai-tty-utils = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
uuid = { workspace = true, features = ["v4"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = { workspace = true }
|
||||
|
||||
# objc2 runtime messaging (links libobjc only, NOT AppKit) for the fast
|
||||
# NSPasteboard probes and the paste-time native image read in clipboard.rs.
|
||||
# AppKit itself is dlopen'd lazily at first probe so headless processes never
|
||||
# load it — preserving the no-AppKit-link rule in clipboard.rs. Version
|
||||
# matches the workspace lock.
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6.3"
|
||||
|
||||
# arboard for clipboard on non-macOS platforms. On macOS we use pbcopy/pbpaste
|
||||
# subprocesses to avoid linking AppKit, which causes ~2 GB IOAccelerator GPU
|
||||
# memory overhead in headless processes (see clipboard.rs).
|
||||
# `wayland-data-control` makes arboard set the Wayland selection through the
|
||||
# compositor-side data-control protocol (no surface, no focus) instead of the
|
||||
# focus-mediated XWayland bridge; no-op off Linux (target-gated inside arboard).
|
||||
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||
arboard = { workspace = true, features = ["wayland-data-control"] }
|
||||
|
||||
# Already in-graph via arboard's wayland-data-control; direct dep for the
|
||||
# data-control probe in clipboard.rs (`is_primary_selection_supported`).
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
wl-clipboard-rs = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
37
crates/codegen/xai-grok-shared/examples/clipboard_probe.rs
Normal file
37
crates/codegen/xai-grok-shared/examples/clipboard_probe.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
//! Reproducible harness for benchmarking clipboard attachment reads
|
||||
//! (the paste hot path).
|
||||
//!
|
||||
//! Runs one `get_attachments()` — the same unified probe the pager's paste
|
||||
//! pipeline executes — and prints the outcome plus wall time. Benchmark the
|
||||
//! native in-process read against the `osascript` fallback with hyperfine:
|
||||
//!
|
||||
//! ```text
|
||||
//! # put an image on the pasteboard first, e.g.:
|
||||
//! # osascript -e 'set the clipboard to (read (POSIX file "shot.png") as «class PNGf»)'
|
||||
//! cargo build --release -p xai-grok-shared --example clipboard_probe
|
||||
//! hyperfine --warmup 2 \
|
||||
//! -n native './target/release/examples/clipboard_probe' \
|
||||
//! -n osascript 'GROK_CLIPBOARD_NO_NATIVE_READ=1 ./target/release/examples/clipboard_probe'
|
||||
//! ```
|
||||
//!
|
||||
//! Exits non-zero when the read errors so hyperfine aborts loudly instead of
|
||||
//! averaging failures.
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let started = std::time::Instant::now();
|
||||
let attachments = xai_grok_shared::clipboard::get_attachments()?;
|
||||
let elapsed_ms = started.elapsed().as_secs_f64() * 1e3;
|
||||
|
||||
let image = attachments
|
||||
.image
|
||||
.map(|img| format!("{} ({} bytes)", img.mime_type, img.data.len()));
|
||||
let file_urls = attachments
|
||||
.file_urls
|
||||
.map(|urls| format!("{} path(s)", urls.lines().count()));
|
||||
println!(
|
||||
"{elapsed_ms:.1} ms image={} file_urls={}",
|
||||
image.as_deref().unwrap_or("none"),
|
||||
file_urls.as_deref().unwrap_or("none"),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
3251
crates/codegen/xai-grok-shared/src/clipboard.rs
Normal file
3251
crates/codegen/xai-grok-shared/src/clipboard.rs
Normal file
File diff suppressed because it is too large
Load diff
9
crates/codegen/xai-grok-shared/src/lib.rs
Normal file
9
crates/codegen/xai-grok-shared/src/lib.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
//! Shared utilities used by both `xai-grok-shell` and its downstream clients
|
||||
//! (e.g. `xai-grok-pager-render`). This crate sits upstream of `xai-grok-shell`
|
||||
//! so it must never depend on it.
|
||||
|
||||
pub mod clipboard;
|
||||
pub mod placeholder_images;
|
||||
pub mod session;
|
||||
pub mod stderr;
|
||||
pub mod ui_config;
|
||||
1526
crates/codegen/xai-grok-shared/src/placeholder_images.rs
Normal file
1526
crates/codegen/xai-grok-shared/src/placeholder_images.rs
Normal file
File diff suppressed because it is too large
Load diff
8
crates/codegen/xai-grok-shared/src/session/info.rs
Normal file
8
crates/codegen/xai-grok-shared/src/session/info.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
use agent_client_protocol as acp;
|
||||
|
||||
/// Session identity: `id` + `cwd`.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Info {
|
||||
pub id: acp::SessionId,
|
||||
pub cwd: String,
|
||||
}
|
||||
13
crates/codegen/xai-grok-shared/src/session/mod.rs
Normal file
13
crates/codegen/xai-grok-shared/src/session/mod.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
pub mod info;
|
||||
|
||||
pub use info::Info;
|
||||
|
||||
// Re-export shared feedback wire types used by downstream crates
|
||||
// (e.g. xai-grok-pager-render).
|
||||
pub use prod_mc_cli_chat_proxy_types::feedback_types::FeedbackTerminalInfo;
|
||||
|
||||
pub fn session_dir(info: &Info) -> PathBuf {
|
||||
xai_grok_tools::util::grok_home::sessions_cwd_dir(&info.cwd).join(info.id.to_string())
|
||||
}
|
||||
50
crates/codegen/xai-grok-shared/src/stderr.rs
Normal file
50
crates/codegen/xai-grok-shared/src/stderr.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
//! Serialized access to the TUI's stderr writer.
|
||||
|
||||
use parking_lot::{Mutex, MutexGuard};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static STDERR_OUTPUT_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
|
||||
fn stderr_output_lock() -> &'static Mutex<()> {
|
||||
STDERR_OUTPUT_LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
pub fn stderr_lock() -> MutexGuard<'static, ()> {
|
||||
stderr_output_lock().lock()
|
||||
}
|
||||
|
||||
/// Execute `f` with exclusive access to the TUI's stderr writer.
|
||||
///
|
||||
/// When [`xai_tty_utils::redirect_native_stderr`] has been called, this
|
||||
/// writes to the dup'd fd that points at the real terminal (bypassing the
|
||||
/// `/dev/null` redirect on fd 2). Otherwise falls back to normal stderr.
|
||||
pub fn with_locked_stderr<T>(f: impl FnOnce(&mut std::fs::File) -> T) -> T {
|
||||
let _guard = stderr_lock();
|
||||
let mut file = xai_tty_utils::dup_tui_stderr().unwrap_or_else(|_| {
|
||||
// Fallback: try_clone stderr to get an independently-owned
|
||||
// File. This path is hit if redirect_native_stderr was never
|
||||
// called or fd dup fails.
|
||||
let stderr = std::io::stderr();
|
||||
let stderr_file: std::fs::File;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::io::{AsRawFd, FromRawFd};
|
||||
// SAFETY: stderr fd (2) is valid; from_raw_fd takes ownership
|
||||
// of the dup'd copy, not the original.
|
||||
let fd = unsafe { libc::dup(stderr.as_raw_fd()) };
|
||||
stderr_file = unsafe { std::fs::File::from_raw_fd(fd) };
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
use std::os::windows::io::{AsRawHandle, FromRawHandle};
|
||||
// SAFETY: stderr handle is valid; DuplicateHandle gives us
|
||||
// an independent copy.
|
||||
let handle = stderr.as_raw_handle();
|
||||
let temp = unsafe { std::fs::File::from_raw_handle(handle) };
|
||||
stderr_file = temp.try_clone().expect("dup stderr fallback");
|
||||
std::mem::forget(temp);
|
||||
}
|
||||
stderr_file
|
||||
});
|
||||
f(&mut file)
|
||||
}
|
||||
369
crates/codegen/xai-grok-shared/src/ui_config.rs
Normal file
369
crates/codegen/xai-grok-shared/src/ui_config.rs
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use xai_grok_config_types::DisplayRefreshSettings;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct UiConfig {
|
||||
pub max_thoughts_width: u16,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub theme: Option<String>,
|
||||
/// Model ID to use for the secondary agent when forking.
|
||||
/// Defaults to the main default model (from default_models.json).
|
||||
pub fork_secondary_model: String,
|
||||
/// YOLO mode. Read by `util::config`, declared here for `serde_ignored`.
|
||||
#[serde(default)]
|
||||
pub yolo: bool,
|
||||
/// UI theme alias. Read by `util::config`, declared here for `serde_ignored`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ui_theme: Option<String>,
|
||||
/// Compact mode. Read by pager, declared here for `serde_ignored`.
|
||||
#[serde(default)]
|
||||
pub compact_mode: bool,
|
||||
/// Simple mode. Read by pager, declared here for `serde_ignored`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub simple_mode: Option<bool>,
|
||||
/// Read by `load_permission_mode()`. Declared for `serde_ignored`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub permission_mode: Option<String>,
|
||||
/// Legacy name for `permission_mode`. Declared for `serde_ignored`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub approval_mode: Option<String>,
|
||||
/// Which permission option the cursor preselects on the **first**
|
||||
/// permission prompt of a session. One of `allow_once`, `allow_always`,
|
||||
/// or `reject`. After the first prompt, the cursor sticks to the user's
|
||||
/// last-used option kind. When unset, the first prompt preselects the
|
||||
/// "Always allow on all sessions" (enable-always-approve) row. Read by
|
||||
/// the pager's permission view.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_selected_permission: Option<String>,
|
||||
/// Written by the pager's appearance persist module.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub show_timestamps: Option<bool>,
|
||||
/// Timeline sidebar (per-turn tick rail in place of the scrollbar).
|
||||
/// `None` = off (client default; opt-in). Written by the pager's settings modal.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub show_timeline: Option<bool>,
|
||||
/// Theme to use when the OS is in dark mode. Written by the pager's theme persist module.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auto_dark_theme: Option<String>,
|
||||
/// Theme to use when the OS is in light mode. Written by the pager's theme persist module.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auto_light_theme: Option<String>,
|
||||
/// Mouse-wheel and trackpad scroll speed multiplier (1–100).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub scroll_speed: Option<u8>,
|
||||
/// Force scroll input classification (`auto` | `wheel` | `trackpad`).
|
||||
/// Written by the pager's settings modal; unset defaults to `auto`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub scroll_mode: Option<String>,
|
||||
/// Invert vertical scroll direction ("natural" scrolling).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub invert_scroll: Option<bool>,
|
||||
/// Lines per scroll tick, applied to BOTH wheel and trackpad pricing
|
||||
/// (1–10). Unset keeps the per-terminal scroll profile's values.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub scroll_lines: Option<u8>,
|
||||
/// Vim-style scrollback navigation (hjkl, gg/G, /).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub vim_mode: Option<bool>,
|
||||
/// How ` ```mermaid ` code blocks are rendered (`auto` | `on` | `off`).
|
||||
/// Written by the pager's settings modal.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub render_mermaid: Option<String>,
|
||||
/// Hunk-tracker mode the pager advertises to the agent (`agent_only` |
|
||||
/// `all_dirty` | `off`). Written by the pager's settings modal; read at
|
||||
/// connect time (CLI `--hunk-tracker-mode` / `GROK_HUNK_TRACKER` override
|
||||
/// it). `off` disables hunk tracking entirely.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub hunk_tracker_mode: Option<String>,
|
||||
/// Voice capture chord behavior: `toggle` or `hold` (hold-to-talk; needs a
|
||||
/// Kitty-protocol terminal, else falls back to toggle). Written by the
|
||||
/// settings modal; unset defaults to `hold`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub voice_capture_mode: Option<String>,
|
||||
/// Speech-to-text language preference for voice dictation. A Grok STT
|
||||
/// catalog code (`en`, `es`, `ja`, … — see xAI STT supported languages) or
|
||||
/// `auto` (system locale, resolved at connect). Written by the settings
|
||||
/// modal; unset leaves `[voice].language` / default `en`. When set, overrides
|
||||
/// `[voice].language` for the session.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub voice_stt_language: Option<String>,
|
||||
/// When `true`, registers `Ctrl+R` (while scrollback is focused) to toggle
|
||||
/// terminal mouse reporting (mouse capture) so users can hand selection back
|
||||
/// to the terminal for native click-drag copy/paste. Opt-in only; unset/false
|
||||
/// leaves mouse reporting always on with no toggle shortcut. The prompt keeps
|
||||
/// `Ctrl+R` for history search — focus scrollback (Esc/Tab) first.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mouse_reporting_toggle: Option<bool>,
|
||||
/// When cancelling a parent turn with running subagents: `always_stop` stops
|
||||
/// them without prompting, `always_continue` leaves them running without
|
||||
/// prompting. Unset/`ask` shows the cancel-turn picker. Written by the pager
|
||||
/// when the user picks "Always stop" / "Always continue".
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cancel_subagents_on_turn_cancel: Option<String>,
|
||||
/// User knob for the `remember_tool_approvals` gate: when `true`, permission
|
||||
/// prompts show the granular per-tool "Always allow …" options. Written by
|
||||
/// the settings modal; requirements/env/managed/remote settings also feed the
|
||||
/// effective gate.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub remember_tool_approvals: Option<bool>,
|
||||
/// In-app drag selection highlight: `flash` | `hold` (legacy bool accepted).
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
deserialize_with = "deserialize_keep_text_selection"
|
||||
)]
|
||||
pub keep_text_selection: Option<String>,
|
||||
/// Legacy TTL ms; only `Some(0)` counts when `keep_text_selection` is unset.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub selection_highlight_duration_ms: Option<u64>,
|
||||
/// Show agent thinking/reasoning blocks in the TUI scrollback.
|
||||
/// `None` = on (client default). Written by the pager's settings modal.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub show_thinking_blocks: Option<bool>,
|
||||
/// Fold runs of consecutive non-destructive tool calls (reads, searches,
|
||||
/// lists) into one transcript row. `None` = on (client default). Written
|
||||
/// by the pager's settings modal.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub group_tool_verbs: Option<bool>,
|
||||
/// Show Edit tool calls as a collapsed one-line `+N/-M` diffstat summary
|
||||
/// by default (expand for the diff). `None` = off (client default).
|
||||
/// Written by the pager's settings modal.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub collapsed_edit_blocks: Option<bool>,
|
||||
/// Next-prompt suggestions (tab autocomplete ghost text) after each turn.
|
||||
/// `None` = on (client default). Written by the pager's settings modal;
|
||||
/// the `GROK_PROMPT_SUGGESTIONS` env var overrides at runtime.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_suggestions: Option<bool>,
|
||||
/// Startup cursor style: `None` (default) inherits the terminal's own
|
||||
/// style; `Some(true)` forces the legacy blinking block, `Some(false)` a
|
||||
/// steady block. Config-file-only knob (no /settings row).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cursor_blink: Option<bool>,
|
||||
/// `"fullscreen"` | `"minimal"`; unset → product default fullscreen.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub screen_mode: Option<String>,
|
||||
/// Retired hidden opt-in for terminal-like double/triple-click word/line
|
||||
/// selection. Superseded by `keep_text_selection = "word_select"`. Still
|
||||
/// read only when `keep_text_selection` is unset; Settings clears this on
|
||||
/// write. `"word_select"` | unset.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub double_click_action: Option<String>,
|
||||
/// Per-tip contextual-hint opt-outs (`[ui.contextual_hints]`). Each `None`
|
||||
/// inherits the remote/default (on); `Some` is a user-explicit choice that
|
||||
/// beats the remote tier. Skipped on the wire when untouched so the section
|
||||
/// only appears once a user toggles a tip.
|
||||
#[serde(default, skip_serializing_if = "ContextualHints::is_default")]
|
||||
pub contextual_hints: ContextualHints,
|
||||
/// Display-refresh probe + auto-cadence (`[ui.display_refresh]`). Per-field
|
||||
/// `None` inherits remote/default; skipped when untouched.
|
||||
#[serde(default, skip_serializing_if = "DisplayRefreshSettings::is_default")]
|
||||
pub display_refresh: DisplayRefreshSettings,
|
||||
}
|
||||
|
||||
/// User-config opt-outs for the per-tip contextual hints, serialized as
|
||||
/// `[ui.contextual_hints]`. Per-field `None` means "inherit remote/default";
|
||||
/// `Some(bool)` is a user-explicit choice (needed so the resolver can let it
|
||||
/// beat the remote tier).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ContextualHints {
|
||||
/// Undo tip (Ctrl+Z after a substantial draft wipe).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub undo: Option<bool>,
|
||||
/// Plan-mode nudge (typing a planning keyword).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub plan_mode: Option<bool>,
|
||||
/// Clipboard-image input tip.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub image_input: Option<bool>,
|
||||
/// Send-now tip after queuing a mid-turn follow-up (InterjectPrompt chord).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub send_now: Option<bool>,
|
||||
/// Small-screen tip (`/compact-mode` hint on smallish terminals).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub small_screen: Option<bool>,
|
||||
/// Word-select tip after double-clicking scrollback while Text selection
|
||||
/// is still fold/nav (`flash` / `hold`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub word_select: Option<bool>,
|
||||
}
|
||||
|
||||
impl ContextualHints {
|
||||
/// True when no tip has a user-explicit value (all inherit). Lets the
|
||||
/// section stay absent from `config.toml` until the user toggles a tip.
|
||||
pub fn is_default(&self) -> bool {
|
||||
self.undo.is_none()
|
||||
&& self.plan_mode.is_none()
|
||||
&& self.image_input.is_none()
|
||||
&& self.send_now.is_none()
|
||||
&& self.small_screen.is_none()
|
||||
&& self.word_select.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_THOUGHTS_WIDTH: u16 = 120;
|
||||
|
||||
fn deserialize_keep_text_selection<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Raw {
|
||||
Bool(bool),
|
||||
Str(String),
|
||||
}
|
||||
|
||||
Ok(
|
||||
Option::<Raw>::deserialize(deserializer)?.map(|raw| match raw {
|
||||
Raw::Bool(true) => "hold".to_string(),
|
||||
Raw::Bool(false) => "flash".to_string(),
|
||||
Raw::Str(s) => s,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
impl Default for UiConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_thoughts_width: DEFAULT_MAX_THOUGHTS_WIDTH,
|
||||
theme: None,
|
||||
fork_secondary_model: xai_grok_models::default_model().to_string(),
|
||||
yolo: false,
|
||||
ui_theme: None,
|
||||
compact_mode: false,
|
||||
simple_mode: None,
|
||||
permission_mode: None,
|
||||
approval_mode: None,
|
||||
default_selected_permission: None,
|
||||
show_timestamps: None,
|
||||
show_timeline: None,
|
||||
auto_dark_theme: None,
|
||||
auto_light_theme: None,
|
||||
scroll_speed: None,
|
||||
scroll_mode: None,
|
||||
invert_scroll: None,
|
||||
scroll_lines: None,
|
||||
vim_mode: None,
|
||||
render_mermaid: None,
|
||||
hunk_tracker_mode: None,
|
||||
voice_capture_mode: None,
|
||||
voice_stt_language: None,
|
||||
mouse_reporting_toggle: None,
|
||||
remember_tool_approvals: None,
|
||||
cancel_subagents_on_turn_cancel: None,
|
||||
keep_text_selection: None,
|
||||
selection_highlight_duration_ms: None,
|
||||
show_thinking_blocks: None,
|
||||
group_tool_verbs: None,
|
||||
collapsed_edit_blocks: None,
|
||||
prompt_suggestions: None,
|
||||
cursor_blink: None,
|
||||
screen_mode: None,
|
||||
double_click_action: None,
|
||||
contextual_hints: ContextualHints::default(),
|
||||
display_refresh: DisplayRefreshSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UiConfig {
|
||||
/// The single source of truth for the timeline-sidebar default (opt-in).
|
||||
/// Flip this one line to change the default everywhere.
|
||||
///
|
||||
// TODO: migrate the other boolean UI settings (show_timestamps,
|
||||
// simple_mode, show_thinking_blocks, …) to the same const + resolver
|
||||
// pattern. They currently duplicate their default literal across
|
||||
// cache.rs / config.rs / defs.rs / setters.rs / registry.rs and rely on
|
||||
// the registry drift-guard test to catch mismatches.
|
||||
pub const SHOW_TIMELINE_DEFAULT: bool = false;
|
||||
|
||||
/// Resolved timeline-sidebar setting: the configured value, or
|
||||
/// [`Self::SHOW_TIMELINE_DEFAULT`] when unset. The one place the default
|
||||
/// is applied — every layer (cache, appearance config, settings modal)
|
||||
/// reads through here so they cannot drift.
|
||||
pub fn show_timeline_enabled(&self) -> bool {
|
||||
self.show_timeline.unwrap_or(Self::SHOW_TIMELINE_DEFAULT)
|
||||
}
|
||||
|
||||
/// True when the highlight should not timer-dismiss (`hold` / `word_select`,
|
||||
/// or legacy duration 0).
|
||||
pub fn keep_text_selection_enabled(&self) -> bool {
|
||||
if let Some(ref s) = self.keep_text_selection {
|
||||
return s == "hold" || s == "word_select";
|
||||
}
|
||||
matches!(self.selection_highlight_duration_ms, Some(0))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keep_text_selection_enabled_precedence() {
|
||||
let mut ui = UiConfig::default();
|
||||
assert!(!ui.keep_text_selection_enabled());
|
||||
|
||||
ui.selection_highlight_duration_ms = Some(0);
|
||||
assert!(ui.keep_text_selection_enabled());
|
||||
|
||||
ui.selection_highlight_duration_ms = Some(150);
|
||||
assert!(!ui.keep_text_selection_enabled());
|
||||
|
||||
ui.selection_highlight_duration_ms = Some(0);
|
||||
ui.keep_text_selection = Some("flash".into());
|
||||
assert!(!ui.keep_text_selection_enabled());
|
||||
|
||||
ui.keep_text_selection = Some("hold".into());
|
||||
ui.selection_highlight_duration_ms = Some(999);
|
||||
assert!(ui.keep_text_selection_enabled());
|
||||
|
||||
ui.keep_text_selection = Some("hold".into());
|
||||
ui.selection_highlight_duration_ms = None;
|
||||
assert!(ui.keep_text_selection_enabled());
|
||||
|
||||
// `word_select` implies hold (persistent highlight).
|
||||
ui.keep_text_selection = Some("word_select".into());
|
||||
ui.selection_highlight_duration_ms = None;
|
||||
assert!(ui.keep_text_selection_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_text_selection_deserializes_legacy_bool_and_string() {
|
||||
let from_true: UiConfig = serde_json::from_str(r#"{"keep_text_selection": true}"#).unwrap();
|
||||
assert_eq!(from_true.keep_text_selection.as_deref(), Some("hold"));
|
||||
|
||||
let from_false: UiConfig =
|
||||
serde_json::from_str(r#"{"keep_text_selection": false}"#).unwrap();
|
||||
assert_eq!(from_false.keep_text_selection.as_deref(), Some("flash"));
|
||||
|
||||
let from_hold: UiConfig =
|
||||
serde_json::from_str(r#"{"keep_text_selection": "hold"}"#).unwrap();
|
||||
assert_eq!(from_hold.keep_text_selection.as_deref(), Some("hold"));
|
||||
|
||||
let from_flash: UiConfig =
|
||||
serde_json::from_str(r#"{"keep_text_selection": "flash"}"#).unwrap();
|
||||
assert_eq!(from_flash.keep_text_selection.as_deref(), Some("flash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_refresh_nested_deserialize() {
|
||||
let ui: UiConfig = serde_json::from_str(
|
||||
r#"{"display_refresh": {"auto_cadence_enabled": true, "floor_ms": 7, "probe_enabled": false}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ui.display_refresh.auto_cadence_enabled, Some(true));
|
||||
assert_eq!(ui.display_refresh.floor_ms, Some(7));
|
||||
assert_eq!(ui.display_refresh.probe_enabled, Some(false));
|
||||
assert!(!ui.display_refresh.is_default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_refresh_default_is_skipped_shape() {
|
||||
assert!(DisplayRefreshSettings::default().is_default());
|
||||
let ui = UiConfig::default();
|
||||
assert!(ui.display_refresh.is_default());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue