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}