Skip to main content

zeph_config/
ui.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::path::{Component, Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8use crate::defaults::default_true;
9
10fn default_acp_agent_name() -> String {
11    "zeph".to_owned()
12}
13
14fn default_acp_agent_version() -> String {
15    env!("CARGO_PKG_VERSION").to_owned()
16}
17
18fn default_acp_max_sessions() -> usize {
19    4
20}
21
22fn default_acp_session_idle_timeout_secs() -> u64 {
23    1800
24}
25
26fn default_acp_broadcast_capacity() -> usize {
27    256
28}
29
30fn default_acp_transport() -> AcpTransport {
31    AcpTransport::Stdio
32}
33
34fn default_acp_http_bind() -> String {
35    "127.0.0.1:9800".to_owned()
36}
37
38fn default_acp_discovery_enabled() -> bool {
39    true
40}
41
42fn default_acp_lsp_max_diagnostics_per_file() -> usize {
43    20
44}
45
46fn default_acp_lsp_max_diagnostic_files() -> usize {
47    5
48}
49
50fn default_acp_lsp_max_references() -> usize {
51    100
52}
53
54fn default_acp_lsp_max_workspace_symbols() -> usize {
55    50
56}
57
58fn default_acp_lsp_request_timeout_secs() -> u64 {
59    10
60}
61
62fn default_acp_elicitation_timeout_secs() -> u64 {
63    120
64}
65
66fn default_acp_terminal_timeout_secs() -> u64 {
67    120
68}
69
70fn default_acp_mcp_timeout_secs() -> u64 {
71    300
72}
73
74fn default_acp_notify_ack_timeout_ms() -> u64 {
75    5000
76}
77
78fn default_lsp_mcp_server_id() -> String {
79    "mcpls".into()
80}
81fn default_lsp_token_budget() -> usize {
82    2000
83}
84fn default_lsp_max_per_file() -> usize {
85    20
86}
87fn default_lsp_max_symbols() -> usize {
88    5
89}
90fn default_lsp_call_timeout_secs() -> u64 {
91    5
92}
93
94/// Auth methods recognised by Zeph's ACP handler.
95///
96/// PR 4 MVP restricts this to `Agent` only. Future variants (`EnvVar`, `Terminal`) will
97/// be added in follow-up issues with their sub-struct payloads.
98///
99/// # Examples
100///
101/// ```rust
102/// use zeph_config::AcpAuthMethod;
103/// use serde_json;
104///
105/// let m: AcpAuthMethod = serde_json::from_str(r#""agent""#).unwrap();
106/// assert_eq!(m, AcpAuthMethod::Agent);
107/// assert!(serde_json::from_str::<AcpAuthMethod>(r#""envvar""#).is_err());
108/// ```
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
110#[serde(rename_all = "lowercase")]
111#[non_exhaustive]
112pub enum AcpAuthMethod {
113    /// Vault-backed agent auth — the sole supported method in PR 4.
114    Agent,
115}
116
117impl<'de> serde::Deserialize<'de> for AcpAuthMethod {
118    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
119        let s = String::deserialize(d)?;
120        match s.as_str() {
121            "agent" => Ok(Self::Agent),
122            other => Err(serde::de::Error::unknown_variant(other, &["agent"])),
123        }
124    }
125}
126
127impl std::fmt::Display for AcpAuthMethod {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        match self {
130            Self::Agent => f.write_str("agent"),
131        }
132    }
133}
134
135fn default_acp_auth_methods() -> Vec<AcpAuthMethod> {
136    vec![AcpAuthMethod::Agent]
137}
138
139/// Error returned when parsing an [`AdditionalDir`] fails.
140#[derive(Debug, thiserror::Error)]
141#[non_exhaustive]
142pub enum AdditionalDirError {
143    /// The raw path contains a `..` component.
144    #[error("path `{0}` contains `..` traversal")]
145    Traversal(PathBuf),
146    /// The canonical path is a reserved system or credentials location.
147    #[error("path `{0}` is a reserved system or credentials directory")]
148    Reserved(PathBuf),
149    /// `std::fs::canonicalize` failed.
150    #[error("failed to canonicalize `{path}`: {source}")]
151    Canonicalize {
152        path: PathBuf,
153        #[source]
154        source: std::io::Error,
155    },
156}
157
158/// A single entry in the `acp.additional_directories` policy allowlist.
159///
160/// Constructed via [`Self::parse`], which:
161/// 1. Rejects any path containing a `..` component (component-aware check).
162/// 2. Expands a leading `~` to the user's home directory.
163/// 3. Calls `std::fs::canonicalize`.
164/// 4. Rejects paths prefixed by `/proc`, `/sys`, `{HOME}/.ssh`, `{HOME}/.gnupg`, or `{HOME}/.aws`.
165///
166/// # Examples
167///
168/// ```rust,no_run
169/// use zeph_config::AdditionalDir;
170///
171/// let dir = AdditionalDir::parse("/tmp/workspace").unwrap();
172/// assert!(dir.as_path().is_absolute());
173/// assert!(AdditionalDir::parse("/proc/self").is_err());
174/// ```
175#[derive(Clone, PartialEq, Eq)]
176pub struct AdditionalDir(PathBuf);
177
178impl AdditionalDir {
179    /// Parse and validate a raw path as a policy allowlist entry.
180    ///
181    /// # Errors
182    ///
183    /// Returns [`AdditionalDirError`] on traversal, reserved prefix, or canonicalization failure.
184    pub fn parse(raw: impl Into<PathBuf>) -> Result<Self, AdditionalDirError> {
185        let raw: PathBuf = raw.into();
186
187        // Expand leading `~`.
188        let expanded = if raw.starts_with("~") {
189            let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
190            home.join(raw.strip_prefix("~").unwrap_or(&raw))
191        } else {
192            raw.clone()
193        };
194
195        // Reject `..` components (component-aware, not string-based).
196        for component in expanded.components() {
197            if component == Component::ParentDir {
198                return Err(AdditionalDirError::Traversal(raw));
199            }
200        }
201
202        let canon =
203            std::fs::canonicalize(&expanded).map_err(|e| AdditionalDirError::Canonicalize {
204                path: raw.clone(),
205                source: e,
206            })?;
207
208        // Reject reserved locations.
209        let reserved = reserved_prefixes();
210        for prefix in &reserved {
211            if canon.starts_with(prefix) {
212                return Err(AdditionalDirError::Reserved(canon));
213            }
214        }
215
216        Ok(Self(canon))
217    }
218
219    /// Returns the canonicalized path.
220    #[must_use]
221    pub fn as_path(&self) -> &Path {
222        &self.0
223    }
224}
225
226fn reserved_prefixes() -> Vec<PathBuf> {
227    let mut prefixes = vec![PathBuf::from("/proc"), PathBuf::from("/sys")];
228    if let Some(home) = dirs::home_dir() {
229        prefixes.push(home.join(".ssh"));
230        prefixes.push(home.join(".gnupg"));
231        prefixes.push(home.join(".aws"));
232    }
233    prefixes
234}
235
236impl std::fmt::Debug for AdditionalDir {
237    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238        write!(f, "AdditionalDir({:?})", self.0)
239    }
240}
241
242impl std::fmt::Display for AdditionalDir {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        write!(f, "{}", self.0.display())
245    }
246}
247
248impl Serialize for AdditionalDir {
249    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
250        self.0.to_string_lossy().serialize(s)
251    }
252}
253
254impl<'de> serde::Deserialize<'de> for AdditionalDir {
255    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
256        let s = String::deserialize(d)?;
257        Self::parse(s).map_err(serde::de::Error::custom)
258    }
259}
260
261/// Controls how much detail is shown for tool-call messages in the chat view.
262///
263/// Cycled with the `c` key at runtime; persisted in `[tui].tool_density`.
264///
265/// # Examples
266///
267/// ```rust
268/// use zeph_config::ToolDensity;
269///
270/// let d = ToolDensity::default();
271/// assert_eq!(d, ToolDensity::Inline);
272/// assert_eq!(d.cycle(), ToolDensity::Block);
273/// assert_eq!(ToolDensity::Block.cycle(), ToolDensity::Compact);
274/// assert_eq!(ToolDensity::Compact.cycle(), ToolDensity::Inline);
275/// ```
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
277#[serde(rename_all = "lowercase")]
278#[non_exhaustive]
279pub enum ToolDensity {
280    /// Single-line summary only (tool name + line count, no output body).
281    Compact,
282    /// Command line + head/tail-truncated output (default).
283    #[default]
284    Inline,
285    /// Full output body without truncation.
286    Block,
287}
288
289impl ToolDensity {
290    /// Advance to the next density level, wrapping around.
291    ///
292    /// `Compact` → `Inline` → `Block` → `Compact`.
293    ///
294    /// # Examples
295    ///
296    /// ```rust
297    /// use zeph_config::ToolDensity;
298    ///
299    /// assert_eq!(ToolDensity::Compact.cycle(), ToolDensity::Inline);
300    /// assert_eq!(ToolDensity::Inline.cycle(), ToolDensity::Block);
301    /// assert_eq!(ToolDensity::Block.cycle(), ToolDensity::Compact);
302    /// ```
303    #[must_use]
304    pub fn cycle(self) -> Self {
305        match self {
306            Self::Compact => Self::Inline,
307            Self::Inline => Self::Block,
308            Self::Block => Self::Compact,
309        }
310    }
311}
312
313/// Terminal colour capability override for the TUI theme system.
314///
315/// `Auto` runs OS-level detection at startup; any other value forces the specified mode
316/// and skips detection entirely. Resolution is performed once at TUI startup and stored in
317/// the TUI `App` theme.
318///
319/// # Example (TOML)
320///
321/// ```toml
322/// [tui.theme]
323/// color_mode = "truecolor"   # force 24-bit even if $COLORTERM is unset
324/// ```
325///
326/// # Examples
327///
328/// ```rust
329/// use zeph_config::ColorMode;
330///
331/// let mode: ColorMode = toml::from_str("value = \"auto\"")
332///     .map(|t: toml::Table| t["value"].clone().try_into().unwrap())
333///     .unwrap();
334/// assert_eq!(mode, ColorMode::Auto);
335/// ```
336#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
337#[serde(rename_all = "lowercase")]
338#[non_exhaustive]
339pub enum ColorMode {
340    /// Run terminal capability detection at startup (default).
341    #[default]
342    Auto,
343    /// Force 24-bit RGB output; skip capability detection.
344    Truecolor,
345    /// Force RGB → xterm-256 downgrade.
346    Ansi256,
347    /// Force RGB → ANSI-16 downgrade.
348    Ansi16,
349    /// Strip all colour; retain text modifiers only (equivalent to `NO_COLOR`).
350    Never,
351}
352
353/// Theme configuration nested under `[tui.theme]` in TOML.
354///
355/// # Example (TOML)
356///
357/// ```toml
358/// [tui.theme]
359/// name = "zephyr"
360/// color_mode = "auto"
361/// ```
362///
363/// # Examples
364///
365/// ```rust
366/// use zeph_config::ThemeConfig;
367///
368/// let cfg = ThemeConfig::default();
369/// assert_eq!(cfg.name, "");
370/// ```
371#[derive(Debug, Clone, Default, Deserialize, Serialize)]
372#[serde(default)]
373pub struct ThemeConfig {
374    /// Named theme preset (e.g. `"zephyr"`, `"gruvbox-dark"`).
375    ///
376    /// Empty string resolves to the `zephyr` built-in preset.
377    pub name: String,
378    /// Terminal colour capability override. Default: `auto` (detect at runtime).
379    pub color_mode: ColorMode,
380}
381
382/// Controls how much animation the TUI renders.
383///
384/// Set via `[tui] motion = "full" | "minimal" | "off"` in TOML.
385/// Default: `full`.
386///
387/// - `full` — wave animation on the input separator row while busy, no breeze spinner.
388/// - `minimal` — animated breeze spinner (current behaviour before #5096), no wave.
389/// - `off` — no animation at all; input row is frame-invariant even while busy.
390///
391/// # Example (TOML)
392///
393/// ```toml
394/// [tui]
395/// motion = "minimal"
396/// ```
397#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
398#[serde(rename_all = "lowercase")]
399pub enum Motion {
400    /// Wave animation on the input separator row while busy.
401    #[default]
402    Full,
403    /// Animated breeze spinner, no wave.
404    Minimal,
405    /// No animation; input row is frame-invariant.
406    Off,
407}
408
409/// Micro-delight toggles for the TUI dashboard (#5104).
410///
411/// All features default to `true`. The `motion = off` setting in [`TuiConfig`]
412/// acts as a master kill-switch that overrides every individual toggle.
413///
414/// # Example (TOML)
415///
416/// ```toml
417/// [tui.delights]
418/// stream_metrics   = true   # tok/s during streaming + TTFT in status bar
419/// toasts           = true   # ephemeral overlay notifications
420/// completion_flash = true   # accent tint on finished tool groups
421/// smooth_scroll    = true   # eased multi-frame scroll on page jumps
422/// splash_shimmer   = true   # one-shot gradient sweep across the wordmark
423/// ```
424#[allow(clippy::struct_excessive_bools)]
425#[derive(Debug, Clone, Deserialize, Serialize)]
426pub struct DelightsConfig {
427    /// Show tok/s during streaming and TTFT after each turn in the status bar.
428    #[serde(default = "default_true")]
429    pub stream_metrics: bool,
430    /// Ephemeral toast notifications (theme switched, copied, task done).
431    #[serde(default = "default_true")]
432    pub toasts: bool,
433    /// One-frame accent tint when a tool group finishes.
434    #[serde(default = "default_true")]
435    pub completion_flash: bool,
436    /// Eased multi-frame interpolation on page scroll.
437    #[serde(default = "default_true")]
438    pub smooth_scroll: bool,
439    /// One-shot gradient shimmer across the splash wordmark at startup.
440    #[serde(default = "default_true")]
441    pub splash_shimmer: bool,
442}
443
444impl Default for DelightsConfig {
445    fn default() -> Self {
446        Self {
447            stream_metrics: true,
448            toasts: true,
449            completion_flash: true,
450            smooth_scroll: true,
451            splash_shimmer: true,
452        }
453    }
454}
455
456/// TUI (terminal user interface) configuration, nested under `[tui]` in TOML.
457///
458/// # Example (TOML)
459///
460/// ```toml
461/// [tui]
462/// show_source_labels = true
463/// tool_density = "inline"
464/// motion = "full"
465///
466/// [tui.theme]
467/// name = "zephyr"
468/// color_mode = "auto"
469///
470/// [tui.delights]
471/// stream_metrics   = true
472/// toasts           = true
473/// completion_flash = true
474/// smooth_scroll    = true
475/// splash_shimmer   = true
476/// ```
477#[derive(Debug, Clone, Default, Deserialize, Serialize)]
478pub struct TuiConfig {
479    /// Show memory source labels (episodic / semantic / graph) in the message view.
480    /// Default: `false`.
481    #[serde(default)]
482    pub show_source_labels: bool,
483    /// Default tool-output density applied at startup.
484    ///
485    /// Runtime changes via the `c` key are not persisted back to config.
486    /// Default: `inline`.
487    #[serde(default)]
488    pub tool_density: ToolDensity,
489    /// Animation budget for the input separator row.
490    ///
491    /// `full` = wave (default), `minimal` = breeze spinner, `off` = static.
492    #[serde(default)]
493    pub motion: Motion,
494    /// Fleet panel configuration (auto-refresh interval and max sessions displayed).
495    #[serde(default)]
496    pub fleet: FleetConfig,
497    /// Theme and colour capability configuration.
498    #[serde(default)]
499    pub theme: ThemeConfig,
500    /// Micro-delight toggles (tok/s, toasts, flash, scroll, shimmer). All default `true`.
501    ///
502    /// `motion = off` overrides all toggles regardless of their individual values.
503    #[serde(default)]
504    pub delights: DelightsConfig,
505    /// Enable opt-in mouse capture at startup.
506    ///
507    /// When `true`, the terminal forwards scroll-wheel, click, and drag events to
508    /// the TUI. Text selection via Shift+drag still works. Default: `false`.
509    #[serde(default)]
510    pub mouse: bool,
511}
512
513/// Configuration for the TUI fleet panel (#3884).
514#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
515#[serde(default)]
516pub struct FleetConfig {
517    /// How often the fleet panel polls the database for updated session data (seconds).
518    pub refresh_interval_secs: u64,
519    /// Maximum number of sessions to display in the fleet panel.
520    pub max_sessions: u32,
521}
522
523impl Default for FleetConfig {
524    fn default() -> Self {
525        Self {
526            refresh_interval_secs: 5,
527            max_sessions: 50,
528        }
529    }
530}
531
532/// ACP server transport mode.
533#[derive(Debug, Clone, Default, Deserialize, Serialize)]
534#[serde(rename_all = "lowercase")]
535#[non_exhaustive]
536pub enum AcpTransport {
537    /// JSON-RPC over stdin/stdout (default, IDE embedding).
538    #[default]
539    Stdio,
540    /// JSON-RPC over HTTP+SSE and WebSocket.
541    Http,
542    /// Both stdio and HTTP transports active simultaneously.
543    Both,
544}
545
546/// Configuration for a named sub-agent preset in `[[acp.subagents.presets]]`.
547#[derive(Clone, Debug, Default, Deserialize, Serialize)]
548pub struct SubagentPresetConfig {
549    /// Identifier used to reference this preset by name.
550    pub name: String,
551    /// Shell command string to spawn the sub-agent (e.g. `"cargo run -- --acp"`).
552    pub command: String,
553    /// Optional working directory for the spawned subprocess.
554    #[serde(default, skip_serializing_if = "Option::is_none")]
555    pub cwd: Option<PathBuf>,
556    /// Timeout in seconds for the `initialize` + `session/new` handshake. Default: 30.
557    #[serde(default = "default_subagent_handshake_timeout_secs")]
558    pub handshake_timeout_secs: u64,
559    /// Timeout in seconds for a single prompt round-trip. Default: 600.
560    #[serde(default = "default_subagent_prompt_timeout_secs")]
561    pub prompt_timeout_secs: u64,
562}
563
564/// Configuration block for the `[acp.subagents]` TOML section.
565///
566/// # Example
567///
568/// ```toml
569/// [acp.subagents]
570/// enabled = true
571///
572/// [[acp.subagents.presets]]
573/// name = "inner"
574/// command = "cargo run --quiet -- --acp"
575/// ```
576#[derive(Clone, Debug, Default, Deserialize, Serialize)]
577pub struct AcpSubagentsConfig {
578    /// Whether sub-agent spawning is enabled at runtime. Default: `false`.
579    #[serde(default)]
580    pub enabled: bool,
581
582    /// Named presets available via CLI (`zeph acp subagent list`) and TUI palette.
583    #[serde(default)]
584    pub presets: Vec<SubagentPresetConfig>,
585}
586
587fn default_subagent_handshake_timeout_secs() -> u64 {
588    30
589}
590
591fn default_subagent_prompt_timeout_secs() -> u64 {
592    600
593}
594
595/// ACP (Agent Communication Protocol) server configuration, nested under `[acp]` in TOML.
596///
597/// When `enabled = true`, Zeph exposes an ACP endpoint that IDE integrations (e.g. Zed, VS Code)
598/// can connect to for conversational coding assistance. Supports stdio and HTTP transports.
599///
600/// # Example (TOML)
601///
602/// ```toml
603/// [acp]
604/// enabled = true
605/// transport = "stdio"
606/// agent_name = "zeph"
607/// max_sessions = 4
608/// ```
609#[derive(Clone, Deserialize, Serialize)]
610pub struct AcpConfig {
611    /// Enable the ACP server. Default: `false`.
612    #[serde(default)]
613    pub enabled: bool,
614    /// Agent name advertised in the ACP `initialize` response. Default: `"zeph"`.
615    #[serde(default = "default_acp_agent_name")]
616    pub agent_name: String,
617    /// Agent version advertised in the ACP `initialize` response. Default: crate version.
618    #[serde(default = "default_acp_agent_version")]
619    pub agent_version: String,
620    /// Maximum number of concurrent ACP sessions. Default: `4`.
621    #[serde(default = "default_acp_max_sessions")]
622    pub max_sessions: usize,
623    /// Seconds of inactivity before an idle session is closed. Default: `1800`.
624    #[serde(default = "default_acp_session_idle_timeout_secs")]
625    pub session_idle_timeout_secs: u64,
626    /// Broadcast channel capacity for streaming events. Default: `256`.
627    #[serde(default = "default_acp_broadcast_capacity")]
628    pub broadcast_capacity: usize,
629    /// Path to the ACP permission TOML file controlling per-session tool access.
630    #[serde(skip_serializing_if = "Option::is_none")]
631    pub permission_file: Option<std::path::PathBuf>,
632    /// List of `{provider}:{model}` identifiers advertised to the IDE for model switching.
633    /// Example: `["claude:claude-sonnet-4-5", "ollama:llama3"]`
634    #[serde(default)]
635    pub available_models: Vec<String>,
636    /// Transport mode: "stdio" (default), "http", or "both".
637    #[serde(default = "default_acp_transport")]
638    pub transport: AcpTransport,
639    /// Bind address for the HTTP transport.
640    #[serde(default = "default_acp_http_bind")]
641    pub http_bind: String,
642    /// Bearer token for HTTP and WebSocket transport authentication.
643    /// When set, all /acp and /acp/ws requests must include `Authorization: Bearer <token>`.
644    /// Omit for local unauthenticated access. TLS termination is assumed to be handled by a
645    /// reverse proxy.
646    #[serde(skip_serializing_if = "Option::is_none")]
647    pub auth_token: Option<String>,
648    /// Whether to serve the /.well-known/acp.json agent discovery manifest.
649    /// Only effective when transport is "http" or "both". Default: true.
650    #[serde(default = "default_acp_discovery_enabled")]
651    pub discovery_enabled: bool,
652    /// LSP extension configuration (`[acp.lsp]`).
653    #[serde(default)]
654    pub lsp: AcpLspConfig,
655    /// Allowlist of workspace directories that ACP clients may reference in session requests.
656    ///
657    /// Paths are canonicalized at config load; traversal (`..`) and reserved locations
658    /// (`/proc`, `/sys`, `~/.ssh`, `~/.gnupg`, `~/.aws`) are rejected with an error.
659    /// An empty list means clients may not request any additional directories beyond the
660    /// session `cwd`.
661    ///
662    /// This is a **policy** allowlist, not a protocol advertisement: the agent never returns
663    /// `additional_directories` in any response; instead it validates each session request's
664    /// `additional_directories` field against this list and rejects with `invalid_params`
665    /// on any violation.
666    #[serde(default)]
667    pub additional_directories: Vec<AdditionalDir>,
668    /// Auth methods advertised in the ACP `initialize` response.
669    ///
670    /// PR 4 MVP accepts only `"agent"`. Config load fails on any other value so drift
671    /// from the schema is detected at startup rather than silently ignored.
672    #[serde(default = "default_acp_auth_methods")]
673    pub auth_methods: Vec<AcpAuthMethod>,
674    /// Echo `PromptRequest.message_id` onto `PromptResponse.user_message_id` and every
675    /// streamed chunk, enabling IDE-side correlation.
676    ///
677    /// Requires the `unstable-message-id` feature. Default: `true`.
678    #[serde(default = "default_true")]
679    pub message_ids_enabled: bool,
680    /// Sub-agent delegation configuration (`[acp.subagents]`).
681    #[serde(default)]
682    pub subagents: AcpSubagentsConfig,
683    /// Timeout configuration for ACP operations (`[acp.timeouts]`).
684    #[serde(default)]
685    pub timeouts: AcpTimeoutsConfig,
686    /// Model-related configuration parameters (`[acp.model_config]`), advertised to IDE
687    /// clients via the `model_config` `session/set_config_option` category (schema 1.1.0+).
688    #[serde(default)]
689    pub model_config: AcpModelConfigConfig,
690}
691
692impl Default for AcpConfig {
693    fn default() -> Self {
694        Self {
695            enabled: false,
696            agent_name: default_acp_agent_name(),
697            agent_version: default_acp_agent_version(),
698            max_sessions: default_acp_max_sessions(),
699            session_idle_timeout_secs: default_acp_session_idle_timeout_secs(),
700            broadcast_capacity: default_acp_broadcast_capacity(),
701            permission_file: None,
702            available_models: Vec::new(),
703            transport: default_acp_transport(),
704            http_bind: default_acp_http_bind(),
705            auth_token: None,
706            discovery_enabled: default_acp_discovery_enabled(),
707            lsp: AcpLspConfig::default(),
708            additional_directories: Vec::new(),
709            auth_methods: default_acp_auth_methods(),
710            message_ids_enabled: true,
711            subagents: AcpSubagentsConfig::default(),
712            timeouts: AcpTimeoutsConfig::default(),
713            model_config: AcpModelConfigConfig::default(),
714        }
715    }
716}
717
718impl std::fmt::Debug for AcpConfig {
719    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
720        f.debug_struct("AcpConfig")
721            .field("enabled", &self.enabled)
722            .field("agent_name", &self.agent_name)
723            .field("agent_version", &self.agent_version)
724            .field("max_sessions", &self.max_sessions)
725            .field("session_idle_timeout_secs", &self.session_idle_timeout_secs)
726            .field("broadcast_capacity", &self.broadcast_capacity)
727            .field("permission_file", &self.permission_file)
728            .field("available_models", &self.available_models)
729            .field("transport", &self.transport)
730            .field("http_bind", &self.http_bind)
731            .field(
732                "auth_token",
733                &self.auth_token.as_ref().map(|_| "[REDACTED]"),
734            )
735            .field("discovery_enabled", &self.discovery_enabled)
736            .field("lsp", &self.lsp)
737            .field("additional_directories", &self.additional_directories)
738            .field("auth_methods", &self.auth_methods)
739            .field("message_ids_enabled", &self.message_ids_enabled)
740            .field("subagents", &self.subagents)
741            .field("timeouts", &self.timeouts)
742            .field("model_config", &self.model_config)
743            .finish()
744    }
745}
746
747/// Sampling-temperature preset for ACP `model_config` session options.
748///
749/// Maps a discrete, IDE-friendly selector (`"precise"` | `"balanced"` | `"creative"`) onto a
750/// concrete sampling temperature, since the ACP `SessionConfigOption` select type only
751/// supports discrete values, not a free-form numeric input.
752#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
753#[serde(rename_all = "snake_case")]
754pub enum AcpTemperaturePreset {
755    /// Low temperature (0.2) — more deterministic, focused completions.
756    Precise,
757    /// Moderate temperature (0.7) — balanced determinism and variety. Default.
758    #[default]
759    Balanced,
760    /// High temperature (1.0) — more varied, exploratory completions.
761    Creative,
762}
763
764impl AcpTemperaturePreset {
765    /// Returns the concrete sampling temperature for this preset.
766    #[must_use]
767    pub fn temperature(self) -> f64 {
768        match self {
769            Self::Precise => 0.2,
770            Self::Balanced => 0.7,
771            Self::Creative => 1.0,
772        }
773    }
774
775    /// Returns the ACP wire identifier for this preset (`"precise"` | `"balanced"` | `"creative"`).
776    #[must_use]
777    pub fn as_str(self) -> &'static str {
778        match self {
779            Self::Precise => "precise",
780            Self::Balanced => "balanced",
781            Self::Creative => "creative",
782        }
783    }
784}
785
786impl std::str::FromStr for AcpTemperaturePreset {
787    type Err = ();
788
789    fn from_str(s: &str) -> Result<Self, Self::Err> {
790        match s {
791            "precise" => Ok(Self::Precise),
792            "balanced" => Ok(Self::Balanced),
793            "creative" => Ok(Self::Creative),
794            _ => Err(()),
795        }
796    }
797}
798
799/// Model-related configuration parameters configuration, nested under `[acp.model_config]`.
800///
801/// Backs the ACP `model_config` `session/set_config_option` category (schema 1.1.0+), which is
802/// distinct from the `model` category: `model` selects which model is active, `model_config`
803/// adjusts a parameter (e.g. sampling temperature) of the currently selected model.
804///
805/// # Example (TOML)
806///
807/// ```toml
808/// [acp.model_config]
809/// default_temperature_preset = "balanced"
810/// ```
811#[derive(Debug, Clone, Default, Deserialize, Serialize)]
812pub struct AcpModelConfigConfig {
813    /// Default sampling-temperature preset applied to new ACP sessions. Default: `"balanced"`.
814    #[serde(default)]
815    pub default_temperature_preset: AcpTemperaturePreset,
816}
817
818/// Timeout configuration for ACP operations.
819///
820/// These values replace the previously hardcoded 120-second defaults for terminal
821/// and elicitation operations, and the 300-second default for MCP bridge calls.
822#[derive(Debug, Clone, Deserialize, Serialize)]
823pub struct AcpTimeoutsConfig {
824    /// Timeout in seconds for elicitation requests sent to the IDE. Default: 120.
825    #[serde(default = "default_acp_elicitation_timeout_secs")]
826    pub elicitation_secs: u64,
827    /// Timeout in seconds for terminal command execution. Default: 120.
828    #[serde(default = "default_acp_terminal_timeout_secs")]
829    pub terminal_secs: u64,
830    /// Timeout in seconds for MCP bridge operations. Default: 300.
831    #[serde(default = "default_acp_mcp_timeout_secs")]
832    pub mcp_secs: u64,
833    /// Maximum time in milliseconds to wait for a notification ack from the IDE client.
834    ///
835    /// If the IDE client does not acknowledge a session notification within this window,
836    /// `send_notification` returns an error instead of blocking indefinitely. Default: 5000.
837    #[serde(default = "default_acp_notify_ack_timeout_ms")]
838    pub notify_ack_timeout_ms: u64,
839}
840
841impl Default for AcpTimeoutsConfig {
842    fn default() -> Self {
843        Self {
844            elicitation_secs: default_acp_elicitation_timeout_secs(),
845            terminal_secs: default_acp_terminal_timeout_secs(),
846            mcp_secs: default_acp_mcp_timeout_secs(),
847            notify_ack_timeout_ms: default_acp_notify_ack_timeout_ms(),
848        }
849    }
850}
851
852/// Configuration for the ACP LSP extension.
853///
854/// Controls LSP code intelligence features when connected to an IDE that advertises
855/// `meta["lsp"]` capability during ACP `initialize`.
856#[derive(Debug, Clone, Deserialize, Serialize)]
857pub struct AcpLspConfig {
858    /// Enable LSP extension when the IDE supports it. Default: `true`.
859    #[serde(default = "default_true")]
860    pub enabled: bool,
861    /// Automatically fetch diagnostics when `lsp/didSave` notification is received.
862    #[serde(default = "default_true")]
863    pub auto_diagnostics_on_save: bool,
864    /// Maximum diagnostics to accept per file. Default: 20.
865    #[serde(default = "default_acp_lsp_max_diagnostics_per_file")]
866    pub max_diagnostics_per_file: usize,
867    /// Maximum files in `DiagnosticsCache` (LRU eviction). Default: 5.
868    #[serde(default = "default_acp_lsp_max_diagnostic_files")]
869    pub max_diagnostic_files: usize,
870    /// Maximum reference locations returned. Default: 100.
871    #[serde(default = "default_acp_lsp_max_references")]
872    pub max_references: usize,
873    /// Maximum workspace symbol search results. Default: 50.
874    #[serde(default = "default_acp_lsp_max_workspace_symbols")]
875    pub max_workspace_symbols: usize,
876    /// Timeout in seconds for LSP `ext_method` calls. Default: 10.
877    #[serde(default = "default_acp_lsp_request_timeout_secs")]
878    pub request_timeout_secs: u64,
879}
880
881impl Default for AcpLspConfig {
882    fn default() -> Self {
883        Self {
884            enabled: true,
885            auto_diagnostics_on_save: true,
886            max_diagnostics_per_file: default_acp_lsp_max_diagnostics_per_file(),
887            max_diagnostic_files: default_acp_lsp_max_diagnostic_files(),
888            max_references: default_acp_lsp_max_references(),
889            max_workspace_symbols: default_acp_lsp_max_workspace_symbols(),
890            request_timeout_secs: default_acp_lsp_request_timeout_secs(),
891        }
892    }
893}
894
895// ── LSP context injection ─────────────────────────────────────────────────────
896
897/// Minimum diagnostic severity to include in LSP context injection.
898#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
899#[serde(rename_all = "lowercase")]
900#[non_exhaustive]
901pub enum DiagnosticSeverity {
902    #[default]
903    Error,
904    Warning,
905    Info,
906    Hint,
907}
908
909/// Configuration for the diagnostics-on-save hook (`[agent.lsp.diagnostics]`).
910///
911/// Flood control relies on `token_budget` in [`LspConfig`], not a per-file count.
912#[derive(Debug, Clone, Deserialize, Serialize)]
913#[serde(default)]
914pub struct DiagnosticsConfig {
915    /// Enable automatic diagnostics fetching after the `write` tool.
916    pub enabled: bool,
917    /// Maximum diagnostics entries per file.
918    #[serde(default = "default_lsp_max_per_file")]
919    pub max_per_file: usize,
920    /// Minimum severity to include.
921    #[serde(default)]
922    pub min_severity: DiagnosticSeverity,
923}
924impl Default for DiagnosticsConfig {
925    fn default() -> Self {
926        Self {
927            enabled: true,
928            max_per_file: default_lsp_max_per_file(),
929            min_severity: DiagnosticSeverity::default(),
930        }
931    }
932}
933
934/// Configuration for the hover-on-read hook (`[agent.lsp.hover]`).
935#[derive(Debug, Clone, Deserialize, Serialize)]
936#[serde(default)]
937pub struct HoverConfig {
938    /// Enable hover info pre-fetch after the `read` tool. Disabled by default.
939    pub enabled: bool,
940    /// Maximum hover entries per file (Rust-only for MVP).
941    #[serde(default = "default_lsp_max_symbols")]
942    pub max_symbols: usize,
943}
944impl Default for HoverConfig {
945    fn default() -> Self {
946        Self {
947            enabled: false,
948            max_symbols: default_lsp_max_symbols(),
949        }
950    }
951}
952
953/// Top-level LSP context injection configuration (`[agent.lsp]` TOML section).
954#[derive(Debug, Clone, Deserialize, Serialize)]
955#[serde(default)]
956pub struct LspConfig {
957    /// Enable LSP context injection hooks.
958    pub enabled: bool,
959    /// MCP server ID to route LSP calls through (default: "mcpls").
960    #[serde(default = "default_lsp_mcp_server_id")]
961    pub mcp_server_id: String,
962    /// Maximum tokens to spend on injected LSP context per turn.
963    #[serde(default = "default_lsp_token_budget")]
964    pub token_budget: usize,
965    /// Timeout in seconds for each MCP LSP call.
966    #[serde(default = "default_lsp_call_timeout_secs")]
967    pub call_timeout_secs: u64,
968    /// Diagnostics-on-save hook configuration.
969    #[serde(default)]
970    pub diagnostics: DiagnosticsConfig,
971    /// Hover-on-read hook configuration.
972    #[serde(default)]
973    pub hover: HoverConfig,
974}
975impl Default for LspConfig {
976    fn default() -> Self {
977        Self {
978            enabled: false,
979            mcp_server_id: default_lsp_mcp_server_id(),
980            token_budget: default_lsp_token_budget(),
981            call_timeout_secs: default_lsp_call_timeout_secs(),
982            diagnostics: DiagnosticsConfig::default(),
983            hover: HoverConfig::default(),
984        }
985    }
986}
987
988#[cfg(test)]
989mod tests {
990    use super::*;
991
992    #[test]
993    fn acp_auth_method_unknown_variant_fails() {
994        assert!(serde_json::from_str::<AcpAuthMethod>(r#""bearer""#).is_err());
995        assert!(serde_json::from_str::<AcpAuthMethod>(r#""envvar""#).is_err());
996        assert!(serde_json::from_str::<AcpAuthMethod>(r#""Agent""#).is_err());
997    }
998
999    #[test]
1000    fn acp_auth_method_known_variant_succeeds() {
1001        let m = serde_json::from_str::<AcpAuthMethod>(r#""agent""#).unwrap();
1002        assert_eq!(m, AcpAuthMethod::Agent);
1003    }
1004
1005    #[test]
1006    fn additional_dir_rejects_dotdot_traversal() {
1007        let result = AdditionalDir::parse(std::path::PathBuf::from("/tmp/../etc"));
1008        assert!(
1009            matches!(result, Err(AdditionalDirError::Traversal(_))),
1010            "expected Traversal, got {result:?}"
1011        );
1012    }
1013
1014    #[test]
1015    fn additional_dir_rejects_proc() {
1016        // /proc must exist on Linux CI; skip on macOS if not present.
1017        if !std::path::Path::new("/proc").exists() {
1018            return;
1019        }
1020        let result = AdditionalDir::parse(std::path::PathBuf::from("/proc/self"));
1021        assert!(
1022            matches!(result, Err(AdditionalDirError::Reserved(_))),
1023            "expected Reserved, got {result:?}"
1024        );
1025    }
1026
1027    #[test]
1028    fn additional_dir_rejects_ssh() {
1029        let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_owned());
1030        let ssh = std::path::PathBuf::from(format!("{home}/.ssh"));
1031        if !ssh.exists() {
1032            return;
1033        }
1034        let result = AdditionalDir::parse(ssh.clone());
1035        assert!(
1036            matches!(result, Err(AdditionalDirError::Reserved(_))),
1037            "expected Reserved for {ssh:?}, got {result:?}"
1038        );
1039    }
1040
1041    #[test]
1042    fn additional_dir_accepts_tmp() {
1043        let tmp = std::env::temp_dir();
1044        // tempdir always exists; /tmp is not reserved.
1045        match AdditionalDir::parse(tmp.clone()) {
1046            Ok(dir) => {
1047                // canonicalized path stored correctly
1048                assert!(dir.as_path().is_absolute());
1049            }
1050            Err(AdditionalDirError::Canonicalize { .. }) => {
1051                // temp_dir may be a symlink that canonicalizes to something else — acceptable
1052            }
1053            Err(e) => panic!("unexpected error for {tmp:?}: {e:?}"),
1054        }
1055    }
1056}