Files
tty7/src/core/config.rs
T
l0ng-ai 649fdef51e Merge branch 'main' into worktree-code-panel
# Conflicts:
#	src/core/config.rs
#	src/ui/mod.rs
#	src/ui/tab_sidebar.rs
#	src/ui/tab_strip.rs
2026-07-24 15:15:08 +08:00

1323 lines
58 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! User configuration loaded from `~/.config/tty7/config.json`.
//!
//! Every field is optional in the file: a missing or malformed config falls back
//! to the built-in defaults (which mirror the values previously hardcoded across
//! the app), so the terminal always starts cleanly. Parse failures are logged via
//! `log::warn!` rather than panicking.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::OnceLock;
use gpui::{FontFeatures, Global};
use serde::{Deserialize, Serialize};
/// Top-level configuration. Stored as a GPUI global so any view can read it.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct Config {
/// Primary monospace font face.
pub font_family: String,
/// Fallback faces tried, in order, for glyphs the primary lacks.
pub font_fallbacks: Vec<String>,
/// Optional distinct face for bold cells. `None` reuses `font_family` with a
/// synthesized bold weight (the current behavior).
pub font_family_bold: Option<String>,
/// Optional distinct face for italic cells. `None` reuses `font_family` with a
/// synthesized italic slant.
pub font_family_italic: Option<String>,
/// Optional OpenType font features for terminal text. When absent, tty7 keeps
/// terminal-safe defaults and disables contextual ligatures; when present,
/// this map is passed through to gpui as-is (for example `{ "calt": true }`).
pub font_features: Option<FontFeatures>,
/// Base font size in pixels.
pub font_size: f32,
/// Line height as a multiple of the font size (e.g. 1.35 → a 13px font gets
/// ~18px rows). Larger values loosen the vertical rhythm; smaller ones pack
/// rows tighter. Clamped to a sane range when applied.
pub line_height: f32,
/// Startup theme mode: "dark" or "light".
pub theme: String,
/// Selected color theme id. Resolves against the theme registry (built-ins +
/// `~/.config/tty7/themes/*`); unknown ids fall back to the default theme. The
/// native chrome is forced to match the theme's light/dark brightness.
pub theme_preset: String,
/// Follow the OS light/dark appearance. When `true` the active theme is
/// resolved from `theme_preset_light` / `theme_preset_dark` by the current
/// system appearance (switching live when the OS mode flips) and
/// `theme_preset` is ignored; the native chrome follows the OS instead of
/// being pinned to the theme.
pub theme_follow_system: bool,
/// Theme id used while `theme_follow_system` is on and the OS is in light
/// mode. Same registry/fallback rules as `theme_preset`.
pub theme_preset_light: String,
/// Theme id used while `theme_follow_system` is on and the OS is in dark
/// mode. Same registry/fallback rules as `theme_preset`.
pub theme_preset_dark: String,
/// Global window-opacity override, 0.21.0. `None` (the default) follows the
/// active theme's own `opacity`; when set it applies to every theme, so a
/// chosen translucency survives theme switches.
pub window_opacity: Option<f32>,
/// Global window-blur override. `None` follows the active theme's `blur`.
pub window_blur: Option<bool>,
/// Optional keybinding overrides: action name (e.g. "NewTab") → keystroke
/// (e.g. "secondary-t", which is ⌘ on macOS and Ctrl elsewhere). Unknown
/// actions and unparseable keystrokes are ignored (with a warning) so a bad
/// entry never blocks startup.
pub keybindings: HashMap<String, String>,
/// Keybinding preset layered between the built-in defaults and the user's
/// `keybindings` overrides. `"default"` (the default) adds nothing; `"tmux"`
/// remaps pane/tab actions onto `prefix`-led sequences (e.g. `ctrl-b c`).
/// Parsed leniently — an unknown value resolves back to the default preset.
#[serde(default = "default_preset")]
pub keybinding_preset: String,
/// The prefix chord the `tmux` preset builds its sequences from (tmux's
/// `C-b`). Only meaningful when `keybinding_preset` is `"tmux"`. Validated as
/// a gpui keystroke where it's consumed; a common alternative is `ctrl-a`.
#[serde(default = "default_prefix")]
pub prefix: String,
/// Optional shell override for the terminals tty7 spawns. When unset (the
/// default), the platform's default shell is used: the user's login shell on
/// Unix (via `$SHELL`), and PowerShell on Windows (PowerShell 7 when
/// installed, else Windows PowerShell). Set this to run a specific shell
/// instead — e.g. `cmd` / WSL `bash` on Windows, or `fish` / `bash` on Unix.
pub shell: Option<ShellConfig>,
// ── Behavior ────────────────────────────────────────────────────────────
/// Detect URLs (OSC 8 hyperlinks + bare URLs in the text), underline them on
/// hover, and open them on ⌘/Ctrl-click. On by default.
pub link_url: bool,
/// Optional command template run when ⌘/Ctrl-clicking a detected file-path
/// link, instead of tty7's built-in "open in the default app" behavior. The
/// template is tokenized on whitespace and the placeholders `{path}`,
/// `{line}`, and `{column}` are substituted per argument; an argument that
/// contains a placeholder with no value (e.g. `{line}` on a link that has no
/// line number) is dropped. `None` (the default) keeps the built-in open.
/// Example: `"herdr edit {path} --line {line}"`.
pub link_file_command: Option<String>,
/// When a pane is in a detected SSH session, Command-clicking loopback URLs
/// opens them through a temporary local SSH port-forward. Off by default
/// because it starts background `ssh` processes.
pub ssh_loopback_forward: bool,
/// Blink the block cursor while the terminal is focused. On by default; when
/// off the cursor stays solid.
pub cursor_blink: bool,
/// Scrollback lines kept per pane. Clamped to alacritty's ceiling (100 000)
/// in `sanitize`. Only applies to newly spawned/attached panes.
pub scrollback_limit: usize,
/// Where a newly opened tab lands relative to the active one.
#[serde(default, deserialize_with = "de_lenient")]
pub new_tab_position: NewTabPosition,
/// Where the tab bar is rendered: a horizontal strip in the title bar
/// (`top`, the default) or a vertical list down the left side (`left`).
#[serde(default, deserialize_with = "de_lenient")]
pub tab_bar_position: TabBarPosition,
/// Width (px) of the vertical tab sidebar (only meaningful when
/// `tab_bar_position` is `left`). Set by dragging the sidebar's right edge;
/// the live layout re-clamps it to `[180, window_width/2]`.
#[serde(default = "default_sidebar_width")]
pub sidebar_width: f32,
/// Whether the vertical tab sidebar is collapsed out of the layout (only
/// meaningful when `tab_bar_position` is `left`). Distinct from
/// `tab_bar_position`: collapsing hides the rail *without* falling back to
/// the horizontal title-bar strip, so the terminal gets the full width and
/// re-expanding restores the same rail. Toggled by `ToggleLeftPanel`.
#[serde(default)]
pub sidebar_collapsed: bool,
/// Whether the right detail panel (session info / changes / files) is
/// docked open. Toggled by `ToggleRightPanel`.
#[serde(default)]
pub right_panel_visible: bool,
/// Width (px) of the right detail panel. Re-clamped by the live layout the
/// same way `sidebar_width` is.
#[serde(default = "default_right_panel_width")]
pub right_panel_width: f32,
/// Which tab the right detail panel last had selected, so reopening it lands
/// where it was left.
#[serde(default, deserialize_with = "de_lenient")]
pub right_panel_tab: RightPanelTab,
/// How the vertical tab sidebar arranges its rows (only meaningful when
/// `tab_bar_position` is `left`): grouped under a header per git work tree
/// (`repo`, the default), or one flat list (`none`).
#[serde(default, deserialize_with = "de_lenient")]
pub sidebar_grouping: SidebarGrouping,
/// When to post a desktop notification after a long foreground command
/// finishes.
#[serde(default, deserialize_with = "de_lenient")]
pub notify_on_command_finish: NotifyMode,
/// On startup, ask GitHub whether a newer release has shipped and, if so,
/// surface a "download" prompt in Settings → About. Never downloads or
/// self-updates — it only links to the Releases page. On by default; set to
/// `false` to skip the network call entirely (offline / privacy).
pub check_for_updates: bool,
/// Seconds a foreground command must run before it's eligible for a
/// "command finished" notification (further gated by
/// `notify_on_command_finish`). Defaults to 10; clamped in `sanitize` so a
/// hand-edit can't set a degenerate value.
#[serde(default = "default_notify_threshold_secs")]
pub notify_threshold_secs: u64,
/// Restore the previous session (tab/split layout + each pane's cwd) on
/// launch. On by default; when off, every launch starts with a single fresh
/// terminal instead of the last window's layout. The session is still saved
/// on quit — it's just ignored at startup.
#[serde(default = "default_true")]
pub restore_session: bool,
/// Show the system tray / menu bar status item: the icon flips to an
/// attention state when a coding agent needs input, and its menu lists the
/// agent panes. On by default; the tray's poll loop re-reads this every
/// second, so toggling it (Settings or a `config.json` edit) applies live.
#[serde(default = "default_true")]
pub show_tray_icon: bool,
/// How the terminal bell (BEL / `^G`) is signalled. Defaults to a brief
/// visual flash (the current behavior).
#[serde(default, deserialize_with = "de_lenient")]
pub bell: BellMode,
/// Tab at the prompt opens tty7's own completion menu (commands, paths,
/// per-command signatures). On by default. When off — or whenever the
/// engine has nothing to offer — the prompt line is handed to the shell
/// and Tab goes to the PTY, so the shell's native completion (compsys,
/// fzf-tab, …) answers instead.
#[serde(default = "default_true")]
pub tab_completion: bool,
// ── Appearance ──────────────────────────────────────────────────────────
/// The shape drawn for the terminal cursor.
#[serde(default, deserialize_with = "de_lenient")]
pub cursor_style: CursorStyle,
// ── Input / Mouse ───────────────────────────────────────────────────────
/// macOS only: treat the Option (⌥) key as Alt/Meta. On, an Option chord
/// sends the ESC-prefixed sequence Meta bindings expect (Option+B → `ESC b`,
/// readline's backward-word), like Ghostty's `macos-option-as-alt` /
/// iTerm2's "Option as Meta". Off (the default), Option keeps its macOS
/// role of composing special characters (Option+B → `∫`). Ignored on other
/// platforms, where Alt always carries the Meta meaning.
pub macos_option_as_alt: bool,
/// Hide the OS mouse pointer while typing; it reappears on the next mouse
/// move. Off by default.
pub mouse_hide_while_typing: bool,
/// Focus a pane as soon as the mouse moves over it, without a click. Off by
/// default; handy with split panes.
pub focus_follows_mouse: bool,
/// Multiplier applied to mouse-wheel scroll distance. 1.0 = one row per wheel
/// line (the raw amount). Clamped to a sane band in `sanitize`.
pub mouse_scroll_multiplier: f32,
/// Report mouse events (click / drag / wheel) to full-screen apps that ask
/// for them (vim, tmux, htop). On by default. When off, the mouse always
/// stays local — native selection and scrollback — regardless of what the
/// app requested. Holding Shift already forces local behavior for a single
/// gesture even while this is on.
#[serde(default = "default_true")]
pub mouse_reporting: bool,
/// Drop trailing whitespace from each copied line. Off by default.
pub clipboard_trim_trailing_spaces: bool,
/// Copy a mouse selection to the clipboard as soon as the gesture ends,
/// without ⌘C (à la Ghostty/iTerm2's copy-on-select). Off by default —
/// the clipboard is never overwritten by a stray selection unless opted
/// into.
pub copy_on_select: bool,
/// Double-click smart selection: expand the selection to the whole URL,
/// email address, file path, or matching bracket pair under the cursor
/// when the plain word sits inside one. On by default; off restores the
/// bare word-boundary double-click.
#[serde(default = "default_true")]
pub smart_select: bool,
/// Characters (besides whitespace) that end a double-click word
/// selection, in both the terminal grid and the prompt's command editor.
/// The default mirrors alacritty's semantic escape set — note `/ . - _`
/// are *not* separators, so paths select as one word. JSON-only (no GUI
/// widget yet).
#[serde(default = "default_word_separators")]
pub word_separators: String,
/// Window state at launch: normal / maximized / fullscreen.
#[serde(default, deserialize_with = "de_lenient")]
pub startup_mode: StartupMode,
/// Reopen a normal (non-maximized/fullscreen) startup window at the size
/// and position it had when tty7 last quit. On by default; off opens
/// centered at the built-in default size. The remembered geometry itself
/// lives in `window.json` (see [`crate::core::window_state`]), not here.
#[serde(default = "default_true")]
pub remember_window_size: bool,
// ── Shell environment ───────────────────────────────────────────────────
/// Where a shell starts when the client doesn't pass an explicit directory
/// (a new tab inheriting the active pane's cwd, or session restore, always
/// win over this).
#[serde(default)]
pub working_directory: WorkingDirectory,
/// Extra environment variables injected into every spawned shell, on top of
/// the inherited environment. Currently JSON-only (no GUI widget yet); a
/// key/value editor is a future addition.
#[serde(default)]
pub env: HashMap<String, String>,
// ── SSH connection manager ───────────────────────────────────────────────
/// Saved SSH connection profiles (the connection-manager data layer). Secrets
/// never live here — a profile only carries a `credential_ref` naming its OS
/// keychain entry (see [`crate::core::keychain`]). This is distinct from the
/// live `ssh_config` alias *discovery* in [`crate::core::ssh_config`]: these
/// are user-owned, editable profiles that can be imported from `~/.ssh/config`.
#[serde(default)]
pub ssh_profiles: Vec<crate::core::ssh_profile::SshProfile>,
/// Global default for verifying SSH host keys against `known_hosts` on the
/// native (russh) path. On by default (never weaken security silently). A
/// per-profile `verify_host_keys` override wins over this when set; this is
/// the fallback when a profile leaves it unset and for QuickConnect. Turning
/// it off disables unknown/changed-host-key confirmation entirely — a
/// deliberate, documented escape hatch (PRD FR-S4).
#[serde(default = "default_true")]
pub verify_host_keys: bool,
/// Global default for the "confirm before closing a live SSH session"
/// prompt (PRD FR-E3). Off by default (closing is unsurprising for most
/// panes). A per-profile `warn_on_close: Some(true/false)` override wins over
/// this when set; this is the fallback for profiles that leave it unset and
/// for QuickConnect panes.
#[serde(default)]
pub ssh_warn_on_close: bool,
/// Per-profile usage stats driving the palette's frecency ordering (PRD
/// FR-P3): a saved profile's id → how many times it was connected and when it
/// was last used. Bumped on every connect; read to rank the palette's profile
/// rows. Entries for deleted profiles are harmless (never surfaced).
#[serde(default)]
pub ssh_profile_frecency: HashMap<uuid::Uuid, ProfileUsage>,
// ── CLI coding agents ────────────────────────────────────────────────────
/// User-defined agent-detection rules: a command basename → an agent slug
/// (`{"cc": "claude", "my-codex": "codex"}`), so personal wrappers get
/// branded like the agent they launch. Complements the built-in registry in
/// [`crate::core::cli_agent`]; built-ins win on their own names. The daemon
/// reads this once per process (restart the daemon to apply changes).
#[serde(default)]
pub agent_commands: HashMap<String, String>,
/// On session restore, when a pane can't re-attach (the daemon lost it —
/// reboot, daemon restart) but it was running a coding agent whose native
/// session id we captured, type that agent's resume command into the fresh
/// shell (`claude --resume <id>`, `codex resume <id>`, …) so the
/// conversation continues where it left off. cmux-style; on by default.
#[serde(default = "default_true")]
pub restore_agent_sessions: bool,
}
/// One saved profile's usage record for palette frecency (see
/// [`Config::ssh_profile_frecency`]).
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(default)]
pub struct ProfileUsage {
/// Times this profile has been connected.
pub count: u32,
/// Unix timestamp (seconds) of the most recent connect.
pub last_used: u64,
}
impl ProfileUsage {
/// A frecency score combining frequency (how often) with recency (how
/// recently), so the palette floats both heavily-used and just-used profiles
/// to the top. Recency decays smoothly over days; `now` is unix seconds.
pub fn score(&self, now: u64) -> f64 {
if self.count == 0 {
return 0.0;
}
let age_days = now.saturating_sub(self.last_used) as f64 / 86_400.0;
// Frequency, discounted by how stale the last use is (halves ~weekly).
self.count as f64 / (1.0 + age_days / 7.0)
}
}
/// The current unix time in whole seconds (0 before the epoch, which never
/// happens). Used to stamp [`ProfileUsage::last_used`].
pub fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// Policy for a shell's starting directory (see [`Config::working_directory`]).
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(default)]
pub struct WorkingDirectory {
/// Which base directory to use.
#[serde(deserialize_with = "de_lenient")]
pub strategy: WdStrategy,
/// The directory used when `strategy` is [`WdStrategy::Custom`]. Kept even
/// while another strategy is active so toggling back restores the last path.
pub path: String,
}
/// The base-directory strategy for a freshly spawned shell.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum WdStrategy {
/// Inherit the daemon's current directory (falling back to `$HOME` when it's
/// unavailable / a bare `/`). The current behavior.
#[default]
Inherit,
/// Always start in the user's home directory.
Home,
/// Always start in [`WorkingDirectory::path`].
Custom,
}
/// Window state applied when tty7 launches (see [`Config::startup_mode`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum StartupMode {
/// A regular centered window at the default size (the current behavior).
#[default]
Normal,
/// Maximized (zoomed) to fill the work area.
Maximized,
/// Native fullscreen.
Fullscreen,
}
/// The shape drawn for the block cursor (see [`Config::cursor_style`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum CursorStyle {
/// A filled rectangle covering the whole cell (the classic block).
#[default]
Block,
/// A thin vertical bar at the cell's left edge (i-beam).
Bar,
/// A thin horizontal line along the cell's baseline.
Underline,
}
/// Where [`Config::new_tab_position`] inserts a freshly opened tab.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum NewTabPosition {
/// Immediately after the currently active tab (the current behavior).
#[default]
AfterCurrent,
/// At the very end of the tab strip.
End,
}
/// Where the tab bar is rendered (see [`Config::tab_bar_position`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum TabBarPosition {
/// A horizontal strip of chips in the title bar (the current behavior).
#[default]
Top,
/// A vertical list down the left side of the window (a tab sidebar).
Left,
}
/// How the vertical tab sidebar arranges its rows (see
/// [`Config::sidebar_grouping`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SidebarGrouping {
/// Group tabs under a header per git repository (linked worktrees fold
/// into their main checkout's group), with non-repo tabs
/// collected in a trailing "Scratch" group. Branch changes and cds inside
/// a repo never move a tab; only changing repos does.
#[default]
Repo,
/// One flat list in tab order (the pre-grouping behavior).
None,
}
/// When tty7 posts a "command finished" desktop notification (see
/// [`Config::notify_on_command_finish`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum NotifyMode {
/// Never notify.
Never,
/// Only when the window is not currently focused (the current behavior).
#[default]
Unfocused,
/// Always, even when the window is focused.
Always,
}
/// How the terminal bell (BEL / `^G`) is signalled (see [`Config::bell`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum BellMode {
/// Ignore the bell entirely — no flash, no sound.
None,
/// A brief visual flash of the terminal (the current behavior).
#[default]
Visual,
/// Ring the system bell. On platforms without one, falls back to a flash so
/// an opted-in bell is never silent.
Audible,
}
/// A shell program plus its launch arguments. Mirrors `alacritty_terminal`'s
/// `tty::Shell`, but lives here so config has no dependency on the PTY crate and
/// the daemon can read it straight from `config.json`.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct ShellConfig {
/// Executable to launch. Either a bare name resolved via `PATH`
/// (e.g. `"pwsh"`, `"bash"`) or an absolute path
/// (e.g. `"C:\\Windows\\System32\\cmd.exe"`, `"/usr/bin/fish"`).
pub program: String,
/// Arguments passed to the shell on launch (e.g. `["-l"]` for a login shell,
/// or `["-NoLogo"]` for PowerShell). Empty by default.
#[serde(default)]
pub args: Vec<String>,
}
impl Default for Config {
fn default() -> Self {
// These defaults match the values that used to be hardcoded in
// `TerminalView::new` and `app::apply_theme`.
Self {
// "Hack" is bundled with the app (see `register_bundled_fonts` in
// main.rs), so this default renders identically everywhere without
// relying on a system install. Menlo stays as a safety net.
font_family: "Hack".to_string(),
font_fallbacks: vec![
"Menlo".to_string(),
"Hasklug Nerd Font Mono".to_string(),
// CJK 兜底:Hack 不含中文,缺字时退到等宽中文字体。仅按名字
// 引用(不打包,该字体每字重 ~20MB),用户未安装则跳到下一项。
"Maple Mono NF CN".to_string(),
"Apple Color Emoji".to_string(),
],
font_family_bold: None,
font_family_italic: None,
font_features: None,
font_size: 15.0,
line_height: 1.4,
theme: "light".to_string(),
// The default theme id (mirrors `ui::presets::DEFAULT_ID`; core can't
// depend on ui). Unknown ids fall back to it anyway.
theme_preset: "light".to_string(),
theme_follow_system: false,
// The built-in light/dark pair; each side is user-swappable in
// Settings once "sync with system" is on.
theme_preset_light: "light".to_string(),
theme_preset_dark: "dark".to_string(),
window_opacity: None,
window_blur: None,
keybindings: HashMap::new(),
keybinding_preset: default_preset(),
prefix: default_prefix(),
// `None` → the platform default shell (login shell on Unix,
// PowerShell 7 / Windows PowerShell on Windows), chosen by the
// daemon at spawn time.
shell: None,
// Behavior defaults mirror the values previously hardcoded across the
// app, so exposing them as config changes nothing until the user opts
// out: URL detection on, cursor blinking, 10k scrollback, new tabs
// after the active one, notify only while unfocused.
link_url: true,
link_file_command: None,
ssh_loopback_forward: false,
cursor_blink: true,
scrollback_limit: 10_000,
new_tab_position: NewTabPosition::AfterCurrent,
// Horizontal title-bar strip, matching the long-standing layout;
// `left` opts into the vertical sidebar.
tab_bar_position: TabBarPosition::Top,
sidebar_width: default_sidebar_width(),
sidebar_collapsed: false,
right_panel_visible: false,
right_panel_width: default_right_panel_width(),
right_panel_tab: RightPanelTab::Info,
sidebar_grouping: SidebarGrouping::Repo,
notify_on_command_finish: NotifyMode::Unfocused,
// Opt-out, not opt-in: a stale terminal that never tells you it's
// outdated is the status quo we're fixing. One cheap GET at startup.
check_for_updates: true,
notify_threshold_secs: default_notify_threshold_secs(),
restore_session: true,
show_tray_icon: true,
// Visual flash preserves the pre-config behavior (the bell always
// flashed); opting into None/Audible is a deliberate change.
bell: BellMode::Visual,
tab_completion: true,
cursor_style: CursorStyle::Block,
// Input/mouse defaults preserve today's behavior: Option composes
// characters as macOS ships it (opt into Option-as-Meta); GPUI
// already hides the pointer while typing (its `CursorHideMode`
// default), so that starts `true`; no focus-follows-mouse, raw 1×
// scroll, no copy trim, a normal centered window.
macos_option_as_alt: false,
mouse_hide_while_typing: true,
focus_follows_mouse: false,
mouse_scroll_multiplier: 1.0,
mouse_reporting: true,
clipboard_trim_trailing_spaces: false,
copy_on_select: false,
smart_select: true,
word_separators: default_word_separators(),
startup_mode: StartupMode::Normal,
remember_window_size: true,
working_directory: WorkingDirectory::default(),
env: HashMap::new(),
ssh_profiles: Vec::new(),
verify_host_keys: true,
ssh_warn_on_close: false,
ssh_profile_frecency: HashMap::new(),
agent_commands: HashMap::new(),
restore_agent_sessions: true,
}
}
}
impl Global for Config {}
impl Config {
/// Load the config, falling back to defaults if the file is absent or
/// unreadable, and to defaults (with a warning) if it fails to parse.
pub fn load() -> Self {
let Some(path) = Self::path() else {
return Config::default();
};
let Ok(text) = std::fs::read_to_string(&path) else {
// Missing/unreadable config is the common case — start with defaults.
return Config::default();
};
match serde_json::from_str::<Config>(&text) {
Ok(mut cfg) => {
cfg.sanitize();
cfg
}
Err(e) => {
log::warn!(
"failed to parse config at {}: {e}; using defaults",
path.display()
);
Config::default()
}
}
}
/// Clamp parsed values into sane ranges so a hand-edited or corrupt
/// `config.json` can't crash the renderer (e.g. `font_size: 0` or a tiny
/// `line_height` would round the row height to 0 → divide-by-zero →
/// `usize::MAX` rows → allocation panic on first paint).
fn sanitize(&mut self) {
if !self.font_size.is_finite() || self.font_size <= 0.0 {
self.font_size = Config::default().font_size;
}
self.font_size = self.font_size.clamp(4.0, 256.0);
if !self.line_height.is_finite() || self.line_height <= 0.0 {
self.line_height = Config::default().line_height;
}
self.line_height = self.line_height.clamp(0.5, 4.0);
// Keep scrollback in a sane band: a floor so it's never uselessly tiny,
// and alacritty's own ceiling (a huge value would just balloon memory —
// the emulator caps history there anyway).
self.scrollback_limit = self.scrollback_limit.clamp(100, MAX_SCROLLBACK);
if !self.mouse_scroll_multiplier.is_finite() || self.mouse_scroll_multiplier <= 0.0 {
self.mouse_scroll_multiplier = Config::default().mouse_scroll_multiplier;
}
self.mouse_scroll_multiplier = self.mouse_scroll_multiplier.clamp(0.1, 10.0);
// Keep the notify threshold in a usable band: a 1s floor so it can't fire
// on every trivial command, and a 1-hour ceiling above which "long
// command" stops meaning anything.
self.notify_threshold_secs = self.notify_threshold_secs.clamp(1, 3600);
// A NaN override would make the whole window invisible or poison the
// alpha math; drop it. The floor keeps a hand-edited value from hiding
// the window entirely.
self.window_opacity = self
.window_opacity
.filter(|o| o.is_finite())
.map(|o| o.clamp(0.2, 1.0));
// A corrupt/NaN width would poison `w(px(..))`; keep it in a broad safe
// band (the live layout enforces the real `[180, window/2]` bounds).
if !self.sidebar_width.is_finite() || self.sidebar_width <= 0.0 {
self.sidebar_width = default_sidebar_width();
}
self.sidebar_width = self.sidebar_width.clamp(100.0, 2000.0);
if !self.right_panel_width.is_finite() || self.right_panel_width <= 0.0 {
self.right_panel_width = default_right_panel_width();
}
self.right_panel_width = self.right_panel_width.clamp(100.0, 2000.0);
// An empty or whitespace-only file-open command means "no override"; the
// settings text field yields `""` when cleared, so fold it back to `None`
// rather than trying to run an empty command.
if let Some(command) = &self.link_file_command
&& command.trim().is_empty()
{
self.link_file_command = None;
}
}
/// Write the current config back to disk, creating the parent directory if
/// needed. Used to persist runtime changes (theme toggle, font zoom) so they
/// survive a restart. Failures are logged, never fatal.
pub fn save(&self) {
let Some(path) = Self::path() else {
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match serde_json::to_string_pretty(self) {
Ok(text) => {
if let Err(e) = write_atomic(&path, text.as_bytes()) {
log::warn!("failed to write config at {}: {e}", path.display());
}
}
Err(e) => log::warn!("failed to serialize config: {e}"),
}
}
/// `~/.config/tty7/config.json`.
fn path() -> Option<PathBuf> {
config_path("config.json")
}
}
/// Process-wide override for the config directory. Set once at startup from the
/// `--config-dir` CLI flag (see `main`); `None` means "use the default". Lets a
/// dev build (`cargo dev`) keep its config/session/history out of the real
/// `~/.config/tty7/` so debugging never clobbers your live setup.
static CONFIG_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
/// Pin the config directory for this process. Idempotent — only the first call
/// wins, so call it before any `config_path` use (i.e. before `Config::load`).
pub fn set_config_dir(dir: PathBuf) {
let _ = CONFIG_DIR_OVERRIDE.set(dir);
}
/// The directory every config-dir file lives in. Resolution order:
/// 1. `--config-dir` override (via `set_config_dir`),
/// 2. `$TTY7_CONFIG_DIR` env var,
/// 3. the platform default (see [`default_config_dir`]).
fn config_dir() -> Option<PathBuf> {
if let Some(dir) = CONFIG_DIR_OVERRIDE.get() {
return Some(dir.clone());
}
if let Some(dir) = std::env::var_os("TTY7_CONFIG_DIR").filter(|d| !d.is_empty()) {
return Some(PathBuf::from(dir));
}
default_config_dir()
}
/// Default config directory on Unix: `$HOME/.config/tty7` (the XDG-ish location
/// tty7 has always used).
#[cfg(not(windows))]
fn default_config_dir() -> Option<PathBuf> {
let home = std::env::var_os("HOME").filter(|h| !h.is_empty())?;
Some(PathBuf::from(home).join(".config/tty7"))
}
/// Default config directory on Windows: `%APPDATA%\tty7` (the conventional
/// per-user roaming app-data location), falling back to
/// `%USERPROFILE%\.config\tty7` to mirror the Unix layout if `APPDATA` is unset.
#[cfg(windows)]
fn default_config_dir() -> Option<PathBuf> {
if let Some(appdata) = std::env::var_os("APPDATA").filter(|d| !d.is_empty()) {
return Some(PathBuf::from(appdata).join("tty7"));
}
let profile = std::env::var_os("USERPROFILE").filter(|d| !d.is_empty())?;
Some(PathBuf::from(profile).join(".config").join("tty7"))
}
/// Resolve a file under the config directory (no `dirs` dep). Shared by every
/// config-dir file (`config.json`, `session.json`, `history`).
pub fn config_path(file: &str) -> Option<PathBuf> {
Some(config_dir()?.join(file))
}
/// Write `bytes` to `path` atomically: write to a sibling temp file, fsync, then
/// rename over the target. A crash/power-loss mid-write then leaves either the
/// old file or the new one intact — never a truncated/half-written file that
/// fails to parse and silently reverts the user's settings to defaults. The temp
/// lives in the same directory so the rename stays on one filesystem (atomic).
/// Shared by `Config::save` and `Session::save`.
pub fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write as _;
let dir = path.parent().unwrap_or_else(|| std::path::Path::new("."));
// Per-process-unique temp name so two concurrent writers don't clobber the
// same scratch file (the final rename then resolves last-writer-wins, with no
// torn target either way).
let tmp = dir.join(format!(
".{}.tmp.{}",
path.file_name().and_then(|n| n.to_str()).unwrap_or("out"),
std::process::id()
));
{
let mut f = std::fs::File::create(&tmp)?;
f.write_all(bytes)?;
f.flush()?;
let _ = f.sync_all();
}
match std::fs::rename(&tmp, path) {
Ok(()) => Ok(()),
Err(e) => {
let _ = std::fs::remove_file(&tmp);
Err(e)
}
}
}
/// The resolved config directory, exposed so the daemon spawner can forward it to
/// the detached child as `--config-dir`. We hand the child the *resolved* path
/// rather than rely on inheritance, so the spawned daemon lands in the exact dir
/// the GUI is using (dev and prod each get their own daemon — that isolation is
/// intentional). `None` only when nothing resolves (no override, no env var, no
/// `$HOME`); the caller then omits the flag and lets the child fall back to its
/// own default resolution.
pub fn config_dir_path() -> Option<PathBuf> {
config_dir()
}
/// The user's configured shell override, if any, as `(program, args)`. Loaded
/// straight from `config.json` so the **daemon** process (which has no GPUI
/// `Config` global) can honor it when spawning a PTY. `None` → the daemon picks
/// the platform default (login shell on Unix, PowerShell 7 / Windows PowerShell
/// on Windows).
pub fn shell_command() -> Option<(String, Vec<String>)> {
Config::load().shell.map(|s| (s.program, s.args))
}
/// The forced base directory for a spawned shell, per `working_directory`.
/// `Some(dir)` overrides the daemon's inherit fallback (but not an explicit
/// client-supplied cwd); `None` means "use the inherit fallback" (the default).
/// Read straight from `config.json` so the **daemon** can honor it. `Home`/an
/// empty `Custom` path resolve via `$HOME`.
pub fn working_directory_base() -> Option<PathBuf> {
let wd = Config::load().working_directory;
let home = || std::env::var_os("HOME").map(PathBuf::from);
match wd.strategy {
WdStrategy::Inherit => None,
WdStrategy::Home => home(),
WdStrategy::Custom => {
let p = wd.path.trim();
if p.is_empty() {
home()
} else {
Some(PathBuf::from(p))
}
}
}
}
/// Extra environment variables to inject into every spawned shell, read from
/// `config.json` on the daemon side (which has no GPUI `Config` global).
pub fn extra_env() -> HashMap<String, String> {
Config::load().env
}
/// User-defined agent-detection rules (`agent_commands`), keys lowercased,
/// cached once per process. The daemon consults this from its 0.5 s foreground
/// poll on every pane, so it must not re-read `config.json` each time; the
/// trade-off is that rule edits apply on the next daemon start (the GUI's
/// "Restart daemon" command counts).
pub fn agent_commands_cached() -> &'static HashMap<String, String> {
static CACHE: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
CACHE.get_or_init(|| {
Config::load()
.agent_commands
.into_iter()
.map(|(k, v)| (k.to_ascii_lowercase(), v))
.collect()
})
}
/// Serde default for [`Config::keybinding_preset`]: the no-op `"default"` preset.
fn default_preset() -> String {
"default".to_string()
}
/// Serde default for the several `bool` fields that default to `true` (so a
/// config predating them, or one omitting them, keeps the on-by-default
/// behavior instead of deserializing to `false`).
fn default_true() -> bool {
true
}
/// Serde default for [`Config::word_separators`]: alacritty's stock semantic
/// escape set, the boundary characters double-click word selection used
/// before this was configurable.
fn default_word_separators() -> String {
",│`|:\"' ()[]{}<>\t".to_string()
}
/// Serde default for [`Config::notify_threshold_secs`]: the 10-second floor a
/// command had to cross before this was configurable.
fn default_notify_threshold_secs() -> u64 {
10
}
/// Serde default for [`Config::prefix`]: tmux's classic `C-b`.
fn default_prefix() -> String {
"ctrl-b".to_string()
}
/// Which tab the right detail panel shows.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RightPanelTab {
/// Session facts: cwd, shell, branch, agent.
#[default]
Info,
/// The pane's command history as a navigable outline (OSC 133 marks).
Outline,
/// The pane's working-tree diff.
Changes,
/// The file tree rooted at the pane's repository.
Files,
}
/// Serde default for [`Config::right_panel_width`]: wide enough for a file path
/// plus its `+N M` counts without the tree turning into an ellipsis parade.
fn default_right_panel_width() -> f32 {
260.
}
/// Serde default for [`Config::sidebar_width`]: a comfortable rail width that
/// clears the tab labels without eating too much of the terminal.
fn default_sidebar_width() -> f32 {
220.0
}
/// Upper bound on `scrollback_limit`. Matches alacritty_terminal's own history
/// ceiling — asking for more just wastes memory since the emulator caps there.
pub const MAX_SCROLLBACK: usize = 100_000;
/// Deserialize a field leniently: if it's present but unparseable (e.g. a typo'd
/// enum string), fall back to `Default` with a warning instead of failing the
/// whole `config.json` parse — one bad entry must never reset every other
/// setting to its default. Missing fields are still handled by the container's
/// `#[serde(default)]`, which never calls this.
pub(crate) fn de_lenient<'de, D, T>(deserializer: D) -> Result<T, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::de::DeserializeOwned + Default,
{
let value = serde_json::Value::deserialize(deserializer)?;
Ok(T::deserialize(&value).unwrap_or_else(|e| {
log::warn!("ignoring invalid config value {value}: {e}; using default");
T::default()
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn profile_usage_score_ranks_frequency_and_recency() {
let now = 100_000_000u64;
let day = 86_400u64;
// Never-used scores zero.
assert_eq!(ProfileUsage::default().score(now), 0.0);
// Same recency, more uses ⇒ higher score.
let a = ProfileUsage {
count: 10,
last_used: now,
};
let b = ProfileUsage {
count: 2,
last_used: now,
};
assert!(a.score(now) > b.score(now));
// Same count, more recent ⇒ higher score (recency decays with age).
let recent = ProfileUsage {
count: 3,
last_used: now,
};
let stale = ProfileUsage {
count: 3,
last_used: now - 30 * day,
};
assert!(recent.score(now) > stale.score(now));
}
#[test]
fn ssh_warn_on_close_and_frecency_round_trip() {
let mut cfg = Config::default();
assert!(!cfg.ssh_warn_on_close);
cfg.ssh_warn_on_close = true;
let id = uuid::Uuid::new_v4();
cfg.ssh_profile_frecency.insert(
id,
ProfileUsage {
count: 4,
last_used: 42,
},
);
let json = serde_json::to_string(&cfg).unwrap();
let back: Config = serde_json::from_str(&json).unwrap();
assert!(back.ssh_warn_on_close);
assert_eq!(back.ssh_profile_frecency.get(&id).unwrap().count, 4);
}
#[test]
fn theme_follow_system_defaults_and_round_trips() {
// Old configs (no follow-system keys) must land on off + the built-in
// light/dark pair, so nothing changes until the user opts in.
let cfg: Config = serde_json::from_str(r#"{"theme_preset":"dracula"}"#).unwrap();
assert!(!cfg.theme_follow_system);
assert_eq!(cfg.theme_preset_light, "light");
assert_eq!(cfg.theme_preset_dark, "dark");
assert_eq!(cfg.theme_preset, "dracula");
let mut cfg = Config::default();
cfg.theme_follow_system = true;
cfg.theme_preset_light = "one_light".to_string();
cfg.theme_preset_dark = "dracula".to_string();
let json = serde_json::to_string(&cfg).unwrap();
let back: Config = serde_json::from_str(&json).unwrap();
assert!(back.theme_follow_system);
assert_eq!(back.theme_preset_light, "one_light");
assert_eq!(back.theme_preset_dark, "dracula");
}
#[test]
fn font_features_are_optional_and_parse_as_gpui_features() {
let cfg: Config =
serde_json::from_str(r#"{"font_features":{"calt":true,"liga":1}}"#).unwrap();
let features = cfg.font_features.expect("font features should parse");
assert_eq!(features.is_calt_enabled(), Some(true));
assert!(
features
.tag_value_list()
.iter()
.any(|(tag, value)| tag == "liga" && *value == 1)
);
let default_cfg = Config::default();
assert!(default_cfg.font_features.is_none());
}
#[test]
fn stale_override_keys_are_ignored() {
// Leftover keys from the retired override system are ignored, not fatal.
let cfg: Config = serde_json::from_str(
r##"{"font_size": 20.0, "colors": {"border": "#fff"}, "ansi_colors": {"color1": "#f00"}}"##,
)
.expect("stale override keys must be ignored");
assert_eq!(cfg.font_size, 20.0);
assert_eq!(cfg.theme_preset, "light");
}
#[test]
fn sanitize_clamps_degenerate_font_metrics() {
// A zero/negative/NaN font size or line height would round the row height
// to 0 and crash the renderer (divide-by-zero → usize::MAX rows). Clamp.
let sanitized = |font_size: f32, line_height: f32| {
let mut cfg = Config {
font_size,
line_height,
..Config::default()
};
cfg.sanitize();
(cfg.font_size, cfg.line_height)
};
let (fs, lh) = sanitized(0.0, 0.0);
assert!(fs >= 4.0, "font_size clamped above zero");
assert!(lh >= 0.5, "line_height clamped above zero");
let (fs, lh) = sanitized(f32::NAN, f32::INFINITY);
assert!(fs.is_finite() && fs > 0.0);
assert!(lh.is_finite() && lh > 0.0);
// A sane value is left untouched.
assert_eq!(sanitized(15.0, 1.4), (15.0, 1.4));
}
/// Per-test scratch directory, unique per test name + PID and removed on
/// drop — cleanup runs even when an assertion panics mid-test, so a failed
/// run can't leak state into (or collide with) the next one.
struct TestDir(std::path::PathBuf);
impl TestDir {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!("tty7-test-{name}-{}", std::process::id()));
// A stale copy from a crashed earlier run would poison this one.
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Self(dir)
}
fn path(&self) -> &std::path::Path {
&self.0
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn write_atomic_replaces_contents_and_leaves_no_temp() {
let dir = TestDir::new("atomic");
let target = dir.path().join("data.json");
write_atomic(&target, b"first").unwrap();
assert_eq!(std::fs::read_to_string(&target).unwrap(), "first");
// Overwrite is atomic and complete (no truncation/append residue).
write_atomic(&target, b"second-longer-and-then-short").unwrap();
write_atomic(&target, b"3rd").unwrap();
assert_eq!(std::fs::read_to_string(&target).unwrap(), "3rd");
// The sibling temp file must not linger.
let leftover: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
.collect();
assert!(leftover.is_empty(), "temp file should be renamed away");
}
#[test]
fn behavior_enums_fall_back_leniently_on_bad_values() {
// A typo'd enum string must NOT reset the whole config: font_size is kept,
// and only the bad field falls back to its default.
let cfg: Config = serde_json::from_str(
r#"{"font_size": 20.0, "new_tab_position": "middle", "notify_on_command_finish": "sometimes", "tab_bar_position": "diagonal"}"#,
)
.expect("a bad enum value must not fail the whole parse");
assert_eq!(cfg.font_size, 20.0);
assert_eq!(cfg.new_tab_position, NewTabPosition::AfterCurrent);
assert_eq!(cfg.notify_on_command_finish, NotifyMode::Unfocused);
assert_eq!(cfg.tab_bar_position, TabBarPosition::Top);
// Valid kebab-case values round-trip.
let cfg: Config = serde_json::from_str(
r#"{"new_tab_position": "end", "notify_on_command_finish": "always", "tab_bar_position": "left"}"#,
)
.unwrap();
assert_eq!(cfg.new_tab_position, NewTabPosition::End);
assert_eq!(cfg.notify_on_command_finish, NotifyMode::Always);
assert_eq!(cfg.tab_bar_position, TabBarPosition::Left);
}
#[test]
fn working_directory_defaults_to_inherit_and_parses_kebab() {
let cfg = Config::default();
assert_eq!(cfg.working_directory.strategy, WdStrategy::Inherit);
assert!(cfg.working_directory.path.is_empty());
let cfg: Config = serde_json::from_str(
r#"{"working_directory": {"strategy": "custom", "path": "/tmp/x"}}"#,
)
.unwrap();
assert_eq!(cfg.working_directory.strategy, WdStrategy::Custom);
assert_eq!(cfg.working_directory.path, "/tmp/x");
// A bad strategy value falls back to the default without failing the parse.
let cfg: Config =
serde_json::from_str(r#"{"working_directory": {"strategy": "elsewhere"}}"#).unwrap();
assert_eq!(cfg.working_directory.strategy, WdStrategy::Inherit);
}
#[test]
fn sanitize_clamps_scroll_multiplier_into_band() {
let clamp = |m: f32| {
let mut cfg = Config {
mouse_scroll_multiplier: m,
..Config::default()
};
cfg.sanitize();
cfg.mouse_scroll_multiplier
};
assert_eq!(clamp(1.0), 1.0);
assert_eq!(clamp(0.0), 1.0); // non-positive → default
assert_eq!(clamp(-3.0), 1.0);
assert_eq!(clamp(100.0), 10.0); // ceiling
assert_eq!(clamp(0.01), 0.1); // floor
}
/// The window-opacity override is clamped into its usable band; a NaN is
/// dropped back to "follow theme" rather than poisoning the alpha math.
#[test]
fn sanitize_clamps_window_opacity_override() {
let clamp = |o: Option<f32>| {
let mut cfg = Config {
window_opacity: o,
..Config::default()
};
cfg.sanitize();
cfg.window_opacity
};
assert_eq!(clamp(None), None);
assert_eq!(clamp(Some(0.8)), Some(0.8));
assert_eq!(clamp(Some(0.0)), Some(0.2)); // floor: never invisible
assert_eq!(clamp(Some(2.0)), Some(1.0)); // ceiling
assert_eq!(clamp(Some(f32::NAN)), None); // NaN → follow theme
}
#[test]
fn sanitize_clamps_scrollback_into_band() {
let clamp = |n: usize| {
let mut cfg = Config {
scrollback_limit: n,
..Config::default()
};
cfg.sanitize();
cfg.scrollback_limit
};
assert_eq!(clamp(0), 100); // floor
assert_eq!(clamp(10_000), 10_000); // untouched in-band
assert_eq!(clamp(usize::MAX), MAX_SCROLLBACK); // ceiling
}
#[test]
fn new_terminal_prefs_default_and_parse_leniently() {
// Defaults preserve the pre-config behavior: restore on, mouse reporting
// on, a 10s notify floor, and a visual bell.
let cfg = Config::default();
assert!(cfg.restore_session);
assert!(cfg.mouse_reporting);
assert!(cfg.tab_completion);
assert_eq!(cfg.notify_threshold_secs, 10);
assert_eq!(cfg.bell, BellMode::Visual);
// A config predating these fields keeps the on-by-default booleans (not
// `false`) and the 10s floor.
let cfg: Config = serde_json::from_str(r#"{"font_size": 15.0}"#).unwrap();
assert!(cfg.restore_session);
assert!(cfg.mouse_reporting);
assert!(cfg.tab_completion);
assert_eq!(cfg.notify_threshold_secs, 10);
assert_eq!(cfg.bell, BellMode::Visual);
// The opt-out round-trips.
let cfg: Config = serde_json::from_str(r#"{"tab_completion": false}"#).unwrap();
assert!(!cfg.tab_completion);
// Valid values round-trip; a bad bell string falls back without failing
// the whole parse.
let cfg: Config = serde_json::from_str(
r#"{"restore_session": false, "mouse_reporting": false, "bell": "audible"}"#,
)
.unwrap();
assert!(!cfg.restore_session);
assert!(!cfg.mouse_reporting);
assert_eq!(cfg.bell, BellMode::Audible);
let cfg: Config = serde_json::from_str(r#"{"bell": "loud"}"#).unwrap();
assert_eq!(cfg.bell, BellMode::Visual);
}
#[test]
fn sanitize_clamps_notify_threshold_into_band() {
let clamp = |n: u64| {
let mut cfg = Config {
notify_threshold_secs: n,
..Config::default()
};
cfg.sanitize();
cfg.notify_threshold_secs
};
assert_eq!(clamp(0), 1); // floor
assert_eq!(clamp(10), 10); // untouched in-band
assert_eq!(clamp(100_000), 3600); // ceiling
}
#[test]
fn keybinding_preset_and_prefix_default_and_round_trip() {
// Missing fields fall back to the no-op preset and the tmux-classic prefix.
let cfg = Config::default();
assert_eq!(cfg.keybinding_preset, "default");
assert_eq!(cfg.prefix, "ctrl-b");
let cfg: Config = serde_json::from_str(r#"{"font_size": 15.0}"#).unwrap();
assert_eq!(cfg.keybinding_preset, "default");
assert_eq!(cfg.prefix, "ctrl-b");
// Explicit values survive a parse.
let cfg: Config =
serde_json::from_str(r#"{"keybinding_preset": "tmux", "prefix": "ctrl-a"}"#).unwrap();
assert_eq!(cfg.keybinding_preset, "tmux");
assert_eq!(cfg.prefix, "ctrl-a");
}
#[test]
fn config_deserialize_fills_missing_fields_from_defaults() {
// Only one field present; the rest must fall back via #[serde(default)].
let cfg: Config = serde_json::from_str(r#"{"font_size": 20.0}"#).unwrap();
assert_eq!(cfg.font_size, 20.0);
assert_eq!(cfg.line_height, 1.4); // default preserved
assert_eq!(cfg.font_family, "Hack"); // default preserved
assert_eq!(cfg.theme_preset, "light");
assert!(cfg.keybindings.is_empty());
}
/// Pin the process config dir at a shared temp location so `load`/`save` never
/// touch the real `~/.config`. First-call-wins; every IO test uses the same path.
fn pin_config_dir() {
let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id()));
std::fs::create_dir_all(&dir).ok();
set_config_dir(dir);
}
#[test]
fn save_load_and_shell_command_round_trip_through_disk() {
pin_config_dir();
// Persist a config with a non-default shell + font + an SSH profile, then
// read it back.
let mut cfg = Config {
font_size: 18.0,
..Config::default()
};
cfg.shell = Some(ShellConfig {
program: "fish".to_string(),
args: vec!["-l".to_string()],
});
let mut profile = crate::core::ssh_profile::SshProfile::new("prod-web");
profile.host = "10.0.0.5".to_string();
profile.user = "deploy".to_string();
profile.port = 2222;
profile.auth = crate::core::ssh_profile::AuthMode::PublicKey;
profile.credential_ref = Some(crate::core::keychain::CredentialRef::password(
"deploy", "10.0.0.5", 2222,
));
cfg.ssh_profiles = vec![profile.clone()];
cfg.save();
let loaded = Config::load();
assert_eq!(loaded.font_size, 18.0);
assert_eq!(
loaded.shell.as_ref().map(|s| s.program.as_str()),
Some("fish")
);
// The SSH profile round-trips byte-for-byte, id included, with no plaintext
// secret anywhere (only the credential *ref*).
assert_eq!(loaded.ssh_profiles, vec![profile]);
// `shell_command` reads the same on-disk config for the daemon side.
let (program, args) = shell_command().expect("shell override present");
assert_eq!(program, "fish");
assert_eq!(args, vec!["-l".to_string()]);
}
#[test]
fn ssh_profiles_default_empty_and_parse_from_json() {
// Absent key → empty (a config predating profiles still loads).
let cfg = Config::default();
assert!(cfg.ssh_profiles.is_empty());
let cfg: Config = serde_json::from_str(r#"{"font_size": 15.0}"#).unwrap();
assert!(cfg.ssh_profiles.is_empty());
// A present profile array parses; a bad enum value inside one profile falls
// back leniently instead of failing the whole config parse.
let cfg: Config = serde_json::from_str(
r#"{"ssh_profiles":[{"name":"a","host":"h","auth":"bogus","port":2200}]}"#,
)
.expect("a bad per-profile enum must not fail the whole config parse");
assert_eq!(cfg.ssh_profiles.len(), 1);
assert_eq!(cfg.ssh_profiles[0].name, "a");
assert_eq!(cfg.ssh_profiles[0].port, 2200);
assert_eq!(
cfg.ssh_profiles[0].auth,
crate::core::ssh_profile::AuthMode::Auto
);
}
#[test]
fn config_path_resolves_under_the_pinned_dir() {
pin_config_dir();
let p = config_path("config.json").expect("config path resolves");
assert!(p.ends_with("config.json"));
// `config_dir_path` returns the same parent the files live under.
assert_eq!(p.parent(), config_dir_path().as_deref());
}
}