Skip to main content

agentmux_srv/backend/wconfig/
types.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Configuration type definitions: settings, themes, widgets, connections, bookmarks.
5
6use std::collections::HashMap;
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use crate::backend::obj::MetaMapType;
12
13// ---- Serde helpers (used by skip_serializing_if attributes) ----
14
15pub(crate) fn is_false(v: &bool) -> bool {
16    !v
17}
18
19pub(crate) fn is_zero_f64(v: &f64) -> bool {
20    *v == 0.0
21}
22
23pub(crate) fn is_zero_f32(v: &f32) -> bool {
24    *v == 0.0
25}
26
27/// A list of strings such as `widget:pinned`, read leniently: a value that isn't a list
28/// is ignored with a warning and non-string items are dropped. Failing it would fail the
29/// whole `SettingsType`, as the note on [`deserialize_connections`] explains.
30fn deserialize_string_list<'de, D>(d: D) -> Result<Option<Vec<String>>, D::Error>
31where
32    D: serde::Deserializer<'de>,
33{
34    Ok(match Value::deserialize(d)? {
35        Value::Null => None,
36        Value::Array(items) => Some(
37            items
38                .into_iter()
39                .filter_map(|item| match item {
40                    Value::String(s) => Some(s),
41                    _ => None,
42                })
43                .collect(),
44        ),
45        _ => {
46            tracing::warn!("settings.json: a widget list setting is not a list of names; ignoring it");
47            None
48        }
49    })
50}
51
52/// `settings.json` → `connections`, entry by entry. An entry that doesn't
53/// parse (say `"term:durable": "yes"`) is skipped with a warning; failing it
54/// would fail the whole `SettingsType`, which resets every setting at startup
55/// and drops every later edit on reload. The entry stays on disk untouched.
56fn deserialize_connections<'de, D>(d: D) -> Result<HashMap<String, ConnKeywords>, D::Error>
57where
58    D: serde::Deserializer<'de>,
59{
60    let raw = Value::deserialize(d)?;
61    let Value::Object(entries) = raw else {
62        if !raw.is_null() {
63            tracing::warn!("settings.json: `connections` is not an object; ignoring it");
64        }
65        return Ok(HashMap::new());
66    };
67    Ok(entries
68        .into_iter()
69        .filter_map(|(name, entry)| match serde_json::from_value::<ConnKeywords>(entry) {
70            Ok(keywords) => Some((name, keywords)),
71            Err(e) => {
72                tracing::warn!(connection = %name, error = %e, "settings.json: ignoring this connection's settings");
73                None
74            }
75        })
76        .collect())
77}
78
79pub(crate) fn is_zero_i32(v: &i32) -> bool {
80    *v == 0
81}
82
83// ---- SettingsType ----
84
85/// Application settings. Matches Go's `wconfig.SettingsType` JSON tags.
86/// Fields use pointer-like `Option` for nullable booleans/numbers
87/// to distinguish "not set" from "false/0".
88#[derive(Debug, Clone, Default, Serialize, Deserialize)]
89pub struct SettingsType {
90    // -- App settings --
91    #[serde(rename = "app:*", default, skip_serializing_if = "is_false")]
92    pub app_clear: bool,
93
94    #[serde(rename = "app:dismissarchitecturewarning", default, skip_serializing_if = "is_false")]
95    pub app_dismiss_architecture_warning: bool,
96
97    #[serde(rename = "app:defaultnewblock", default, skip_serializing_if = "String::is_empty")]
98    pub app_default_new_block: String,
99
100    #[serde(rename = "app:showoverlayblocknums", default, skip_serializing_if = "Option::is_none")]
101    pub app_show_overlay_block_nums: Option<bool>,
102
103    // -- Terminal settings --
104    #[serde(rename = "term:*", default, skip_serializing_if = "is_false")]
105    pub term_clear: bool,
106
107    #[serde(rename = "term:fontsize", default, skip_serializing_if = "is_zero_f64")]
108    pub term_font_size: f64,
109
110    #[serde(rename = "term:fontfamily", default, skip_serializing_if = "String::is_empty")]
111    pub term_font_family: String,
112
113    #[serde(rename = "term:theme", default, skip_serializing_if = "String::is_empty")]
114    pub term_theme: String,
115
116    #[serde(rename = "term:disablewebgl", default, skip_serializing_if = "is_false")]
117    pub term_disable_web_gl: bool,
118
119    #[serde(rename = "term:localshellpath", default, skip_serializing_if = "String::is_empty")]
120    pub term_local_shell_path: String,
121
122    #[serde(rename = "term:localshellopts", default, skip_serializing_if = "Vec::is_empty")]
123    pub term_local_shell_opts: Vec<String>,
124
125    #[serde(rename = "term:scrollback", default, skip_serializing_if = "Option::is_none")]
126    pub term_scrollback: Option<i64>,
127
128    /// SSH terminal panes durable (their shell kept on the host by AgentMux's
129    /// helper) or not, unless a pane or its connection says otherwise. Unset:
130    /// durable, asking before the helper is installed on a host without it,
131    /// and plain there if it isn't (`durable_mode::mode`).
132    #[serde(rename = "term:durable", default, skip_serializing_if = "Option::is_none")]
133    pub term_durable: Option<bool>,
134
135    #[serde(rename = "term:copyonselect", default, skip_serializing_if = "Option::is_none")]
136    pub term_copy_on_select: Option<bool>,
137
138    #[serde(rename = "term:transparency", default, skip_serializing_if = "Option::is_none")]
139    pub term_transparency: Option<f64>,
140
141    #[serde(rename = "term:scrollsensitivity", default, skip_serializing_if = "Option::is_none")]
142    pub term_scroll_sensitivity: Option<f64>,
143
144    #[serde(rename = "term:allowbracketedpaste", default, skip_serializing_if = "Option::is_none")]
145    pub term_allow_bracketed_paste: Option<bool>,
146
147    #[serde(rename = "term:shiftenternewline", default, skip_serializing_if = "Option::is_none")]
148    pub term_shift_enter_newline: Option<bool>,
149
150    /// A browser pane's address bar also asks the default search engine for
151    /// suggestions while you type (each edit is sent to it before you submit),
152    /// as Chrome does. Unset: on. Off: suggestions come only from this
153    /// computer (history, typed URLs). Incognito tabs never ask, as in Chrome.
154    #[serde(rename = "browser:searchsuggestions", default, skip_serializing_if = "Option::is_none")]
155    pub browser_search_suggestions: Option<bool>,
156
157    /// Maximum runtime in hours before the watchdog kills an agent pane.
158    /// 0 (default) disables the limit.
159    #[serde(rename = "term:agentmaxruntimehours", default, skip_serializing_if = "is_zero_f64")]
160    pub term_agent_max_runtime_hours: f64,
161
162    /// Minutes of PTY silence before the watchdog kills an idle agent pane.
163    /// 0 (default) disables the limit.
164    #[serde(rename = "term:agentidletimeoutmins", default, skip_serializing_if = "is_zero_f64")]
165    pub term_agent_idle_timeout_mins: f64,
166
167    // -- Command settings --
168    #[serde(rename = "cmd:env", default, skip_serializing_if = "HashMap::is_empty")]
169    pub cmd_env: HashMap<String, String>,
170
171    // -- Block header settings --
172    #[serde(rename = "blockheader:*", default, skip_serializing_if = "is_false")]
173    pub block_header_clear: bool,
174
175    #[serde(rename = "blockheader:showblockids", default, skip_serializing_if = "is_false")]
176    pub block_header_show_block_ids: bool,
177
178    // -- Preview settings --
179    #[serde(rename = "preview:showhiddenfiles", default, skip_serializing_if = "Option::is_none")]
180    pub preview_show_hidden_files: Option<bool>,
181
182    // -- Tab settings --
183    #[serde(rename = "tab:preset", default, skip_serializing_if = "String::is_empty")]
184    pub tab_preset: String,
185
186    // -- Widget settings --
187    #[serde(rename = "widget:*", default, skip_serializing_if = "is_false")]
188    pub widget_clear: bool,
189
190    #[serde(rename = "widget:icononly", default, skip_serializing_if = "Option::is_none")]
191    pub widget_icon_only: Option<bool>,
192
193    /// The widget bar's pinned icons, in order: a built-in's short name (`agent` for
194    /// `defwidget@agent`), or an installed package widget's full key (`ext@<id>/<pane>`). Absent means the fresh-install default: the entries with
195    /// `display:pinned`. Written whole by the bar, its drag reorder and Settings → Widgets.
196    #[serde(
197        rename = "widget:pinned",
198        default,
199        skip_serializing_if = "Option::is_none",
200        deserialize_with = "deserialize_string_list"
201    )]
202    pub widget_pinned: Option<Vec<String>>,
203
204    /// Widget-bar entries the user hid, as full keys (`defwidget@drone`,
205    /// `ext@acme.board/main`): on neither the bar nor More, nor in the pane "+" picker.
206    /// Absent means nothing is hidden. SPEC_WIDGET_BAR_VISIBILITY_SETTINGS_2026_10_12.md.
207    #[serde(
208        rename = "widget:hidden",
209        default,
210        skip_serializing_if = "Option::is_none",
211        deserialize_with = "deserialize_string_list"
212    )]
213    pub widget_hidden: Option<Vec<String>>,
214
215    // -- Status bar settings --
216    /// Whether the status bar shows MuxBus Cloud: the dot after the host name and
217    /// the sign-in block in the host popover. Absent means shown; only an explicit
218    /// `false` hides it. LAN is unaffected. SPEC_STATUSBAR_HIDE_MUXBUS_SETTING_2026_10_08.md.
219    #[serde(rename = "statusbar:showmuxbuscloud", default, skip_serializing_if = "Option::is_none")]
220    pub statusbar_show_muxbus_cloud: Option<bool>,
221
222    // -- Window settings --
223    #[serde(rename = "window:*", default, skip_serializing_if = "is_false")]
224    pub window_clear: bool,
225
226    #[serde(rename = "window:transparent", default, skip_serializing_if = "is_false")]
227    pub window_transparent: bool,
228
229    #[serde(rename = "window:blur", default, skip_serializing_if = "is_false")]
230    pub window_blur: bool,
231
232    #[serde(rename = "window:opacity", default, skip_serializing_if = "Option::is_none")]
233    pub window_opacity: Option<f64>,
234
235    #[serde(rename = "window:bgcolor", default, skip_serializing_if = "String::is_empty")]
236    pub window_bg_color: String,
237
238    #[serde(rename = "window:reducedmotion", default, skip_serializing_if = "is_false")]
239    pub window_reduced_motion: bool,
240
241    #[serde(rename = "window:tilegapsize", default, skip_serializing_if = "Option::is_none")]
242    pub window_tile_gap_size: Option<i64>,
243
244    #[serde(rename = "window:showmenubar", default, skip_serializing_if = "is_false")]
245    pub window_show_menu_bar: bool,
246
247    #[serde(rename = "window:nativetitlebar", default, skip_serializing_if = "is_false")]
248    pub window_native_title_bar: bool,
249
250    #[serde(rename = "window:disablehardwareacceleration", default, skip_serializing_if = "is_false")]
251    pub window_disable_hardware_acceleration: bool,
252
253    #[serde(rename = "window:maxtabcachesize", default, skip_serializing_if = "is_zero_i32")]
254    pub window_max_tab_cache_size: i32,
255
256    #[serde(rename = "window:magnifiedblockopacity", default, skip_serializing_if = "Option::is_none")]
257    pub window_magnified_block_opacity: Option<f64>,
258
259    #[serde(rename = "window:magnifiedblocksize", default, skip_serializing_if = "Option::is_none")]
260    pub window_magnified_block_size: Option<f64>,
261
262    #[serde(rename = "window:magnifiedblockblurprimarypx", default, skip_serializing_if = "Option::is_none")]
263    pub window_magnified_block_blur_primary_px: Option<i64>,
264
265    #[serde(rename = "window:magnifiedblockblursecondarypx", default, skip_serializing_if = "Option::is_none")]
266    pub window_magnified_block_blur_secondary_px: Option<i64>,
267
268    #[serde(rename = "window:confirmclose", default, skip_serializing_if = "is_false")]
269    pub window_confirm_close: bool,
270
271    #[serde(rename = "window:savelastwindow", default, skip_serializing_if = "is_false")]
272    pub window_save_last_window: bool,
273
274    /// Keep inactive window tabs laid out (`visibility: hidden`) instead of
275    /// skipping their layout (`content-visibility: hidden`), so switching
276    /// back has nothing to catch up on. On unless `false` (the frontend
277    /// treats unset as on); see
278    /// docs/analysis/ANALYSIS_WINDOW_TAB_SWITCH_SMOOTHNESS_2026_09_24.md §7.
279    #[serde(rename = "window:keepinactivetabslaidout", default, skip_serializing_if = "Option::is_none")]
280    pub window_keep_inactive_tabs_laid_out: Option<bool>,
281
282    #[serde(rename = "window:dimensions", default, skip_serializing_if = "String::is_empty")]
283    pub window_dimensions: String,
284
285    #[serde(rename = "window:zoom", default, skip_serializing_if = "Option::is_none")]
286    pub window_zoom: Option<f64>,
287
288    // -- Telemetry settings --
289    #[serde(rename = "telemetry:*", default, skip_serializing_if = "is_false")]
290    pub telemetry_clear: bool,
291
292    #[serde(rename = "telemetry:enabled", default, skip_serializing_if = "is_false")]
293    pub telemetry_enabled: bool,
294
295    #[serde(rename = "telemetry:interval", default, skip_serializing_if = "is_zero_f64")]
296    pub telemetry_interval: f64,
297
298    #[serde(rename = "telemetry:numpoints", default, skip_serializing_if = "Option::is_none")]
299    pub telemetry_numpoints: Option<i64>,
300
301    // -- Connection settings --
302    #[serde(rename = "conn:*", default, skip_serializing_if = "is_false")]
303    pub conn_clear: bool,
304
305    /// Installing AgentMux's helper on a host whose own `conn:helper` is
306    /// unset: `ask` (unset), `always` or `never` (SPEC_REMOTES_PANE_2026_10_05.md §4.9).
307    #[serde(rename = "conn:helper", default, skip_serializing_if = "String::is_empty")]
308    pub conn_helper: String,
309
310    // -- Network settings --
311    #[serde(rename = "network:lan_discovery", default, skip_serializing_if = "is_false")]
312    pub network_lan_discovery: bool,
313
314    // -- Voice settings --
315    //
316    // `voice:enabled` globally controls whether the per-pane microphone
317    // button is rendered. The default at the UX layer is "enabled" — the
318    // frontend treats `undefined`/absent as enabled, so we only need to
319    // model the explicit-disable case here. Users set this to `false` in
320    // `settings.json` to fully hide the buttons across all panes.
321    //
322    // Spec: docs/specs/SPEC_VOICE_INPUT_PER_PANE_2026_05_19.md §7 Phase 3.
323    #[serde(rename = "voice:enabled", default, skip_serializing_if = "Option::is_none")]
324    pub voice_enabled: Option<bool>,
325
326    // Speech-to-text engine: "whisper" (capture audio → server STT, the default
327    // and only engine that works in CEF) or "webspeech" (browser API — dev /
328    // real-Chromium only). Absent ⇒ whisper.
329    // Spec: docs/specs/SPEC_VOICE_STT_ENGINE_2026_06_20.md.
330    #[serde(rename = "voice:engine", default, skip_serializing_if = "Option::is_none")]
331    pub voice_engine: Option<String>,
332
333    // Groq API key for the hosted Whisper backend. Read server-side only; never
334    // sent to the renderer. The AGENTMUX_GROQ_API_KEY env var takes precedence.
335    #[serde(rename = "voice:groqApiKey", default, skip_serializing_if = "Option::is_none")]
336    pub voice_groq_api_key: Option<String>,
337
338    // Local whisper.cpp backend (voice:engine = "whisper-local"). Both are
339    // user-provided paths in v1 (auto-download is a follow-up). Env overrides:
340    // AGENTMUX_WHISPER_CLI / AGENTMUX_WHISPER_MODEL.
341    #[serde(rename = "voice:whisperCliPath", default, skip_serializing_if = "Option::is_none")]
342    pub voice_whisper_cli_path: Option<String>,
343    // GGML model name to auto-download on first use (default "base.en"). The
344    // explicit-path override below skips the download.
345    #[serde(rename = "voice:whisperModel", default, skip_serializing_if = "Option::is_none")]
346    pub voice_whisper_model: Option<String>,
347    #[serde(rename = "voice:whisperModelPath", default, skip_serializing_if = "Option::is_none")]
348    pub voice_whisper_model_path: Option<String>,
349
350    // -- Notification sounds --
351    //
352    // Spec: docs/specs/SPEC_SOUND_NOTIFICATIONS_2026_06_05.md §5.1.
353    //
354    // Master enable. Default at the UX layer is "on" — the frontend treats
355    // `undefined`/absent as enabled, so absence means sounds play. Users
356    // set this to `false` to fully silence the app.
357    #[serde(rename = "notify:sounds:enabled", default, skip_serializing_if = "Option::is_none")]
358    pub notify_sounds_enabled: Option<bool>,
359
360    // 0.0 to 1.0; default 0.6 if unset.
361    #[serde(rename = "notify:sounds:volume", default, skip_serializing_if = "Option::is_none")]
362    pub notify_sounds_volume: Option<f32>,
363
364    // Suppress a pane's sound when that pane is focused AND the window
365    // is in foreground. Default: true.
366    #[serde(rename = "notify:sounds:suppresswhenfocused", default, skip_serializing_if = "Option::is_none")]
367    pub notify_sounds_suppress_when_focused: Option<bool>,
368
369    // Per-event opt-out. Absence = on (default behavior).
370    #[serde(rename = "notify:sound:agent.turn.complete", default, skip_serializing_if = "Option::is_none")]
371    pub notify_sound_agent_turn_complete: Option<bool>,
372
373    #[serde(rename = "notify:sound:agent.turn.error", default, skip_serializing_if = "Option::is_none")]
374    pub notify_sound_agent_turn_error: Option<bool>,
375
376    #[serde(rename = "notify:sound:agent.turn.interrupted", default, skip_serializing_if = "Option::is_none")]
377    pub notify_sound_agent_turn_interrupted: Option<bool>,
378
379    #[serde(rename = "notify:sound:agent.message.accepted", default, skip_serializing_if = "Option::is_none")]
380    pub notify_sound_agent_message_accepted: Option<bool>,
381
382    #[serde(rename = "notify:sound:agent.message.rejected", default, skip_serializing_if = "Option::is_none")]
383    pub notify_sound_agent_message_rejected: Option<bool>,
384
385    // -- Tool-call tones (subliminal per-tool "voice") --
386    //
387    // Spec: docs/specs/SPEC_AGENT_TOOL_CALL_TONES_2026_06_05.md.
388    //
389    // Master enable. Default at the UX layer is "on" — absence is on
390    // by design (see spec §7).
391    #[serde(rename = "notify:tooltones:enabled", default, skip_serializing_if = "Option::is_none")]
392    pub notify_tooltones_enabled: Option<bool>,
393
394    // Independent gain (0.0–1.0; default 0.25). Layered below the
395    // shared master gain so master kill-switches both subsystems.
396    #[serde(rename = "notify:tooltones:volume", default, skip_serializing_if = "Option::is_none")]
397    pub notify_tooltones_volume: Option<f32>,
398
399    // Scope: "all" (default) plays for every pane in every window;
400    // "focused" plays only for the focused pane in the focused window.
401    // The intermediate "window" mode is reserved for v1.5.
402    #[serde(rename = "notify:tooltones:scope", default, skip_serializing_if = "Option::is_none")]
403    pub notify_tooltones_scope: Option<String>,
404
405    // Visual twin of each sound from a pane — tool-call tones and the
406    // turn/message event sounds alike, despite the key's name: a flash on
407    // the source's pane-header pill (a brightened version of the pane's
408    // color) and a subtler one on its window tab, one pulse per strike.
409    // Fires exactly when a sound passes its own gates. Absence = on.
410    // SPEC_AGENT_ACTIVITY_TAB_FLASH_2026_09_23.md,
411    // SPEC_AGENT_ACTIVITY_FLASH_SOUND_SYNC_2026_09_24.md.
412    #[serde(rename = "notify:tooltones:flash", default, skip_serializing_if = "Option::is_none")]
413    pub notify_tooltones_flash: Option<bool>,
414    // Whether browser hand-offs and approvals join the waiting tone, the tab
415    // flash and the notification. Absence = on.
416    // REPORT_AGENT_ATTENTION_CTA_CONTRAST_AND_TONE_2026_10_10.md §2.
417    #[serde(rename = "notify:waiting:browser", default, skip_serializing_if = "Option::is_none")]
418    pub notify_waiting_browser: Option<bool>,
419
420    // -- Messaging bridge settings --
421
422    /// Master enable for the Discord messaging bridge.
423    /// When true, the bridge connects to the Discord Gateway at startup.
424    #[serde(rename = "messaging:discord:enabled", default, skip_serializing_if = "is_false")]
425    pub messaging_discord_enabled: bool,
426
427    /// Discord bot token. Treat as a secret — do not log. Obtain from
428    /// discord.com/developers/applications → Bot → Token.
429    #[serde(rename = "messaging:discord:token", default, skip_serializing_if = "Option::is_none")]
430    pub messaging_discord_token: Option<String>,
431
432    /// Channel ID to filter inbound messages and use as the default send target.
433    #[serde(rename = "messaging:discord:channel", default, skip_serializing_if = "String::is_empty")]
434    pub messaging_discord_channel: String,
435
436    /// Agent ID that receives inbound Discord messages via the reactive bus.
437    /// Absent → messages are logged but not forwarded to any agent.
438    #[serde(rename = "messaging:discord:target", default, skip_serializing_if = "Option::is_none")]
439    pub messaging_discord_target: Option<String>,
440
441    /// Guild ID for guild-scoped slash command registration (Phase 2).
442    #[serde(rename = "messaging:discord:guild", default, skip_serializing_if = "Option::is_none")]
443    pub messaging_discord_guild: Option<String>,
444
445    // -- Messaging bridge settings (Telegram) --
446
447    /// Master enable for the Telegram messaging bridge.
448    /// When true, the bridge starts long-polling getUpdates at startup.
449    #[serde(rename = "messaging:telegram:enabled", default, skip_serializing_if = "is_false")]
450    pub messaging_telegram_enabled: bool,
451
452    /// Telegram bot token from @BotFather. Treat as a secret — do not log.
453    #[serde(rename = "messaging:telegram:token", default, skip_serializing_if = "Option::is_none")]
454    pub messaging_telegram_token: Option<String>,
455
456    /// Comma-separated allowlist of chat IDs permitted to reach the bridge.
457    /// Inbound updates from any other chat are silently dropped.
458    /// Stored as a string (not Vec<i64>) to keep the flat-key/settings.json
459    /// convention simple — parsed to Vec<i64> at startup wiring time.
460    #[serde(rename = "messaging:telegram:allowed_chats", default, skip_serializing_if = "String::is_empty")]
461    pub messaging_telegram_allowed_chats: String,
462
463    /// Default chat ID for outbound sends when a request doesn't override one.
464    #[serde(rename = "messaging:telegram:default_chat", default, skip_serializing_if = "Option::is_none")]
465    pub messaging_telegram_default_chat: Option<String>,
466
467    /// Agent ID that receives inbound Telegram messages via the reactive bus.
468    /// Absent → messages are logged but not forwarded to any agent.
469    #[serde(rename = "messaging:telegram:target", default, skip_serializing_if = "Option::is_none")]
470    pub messaging_telegram_target: Option<String>,
471
472    // -- Slack messaging bridge --
473
474    /// Master enable for the Slack messaging bridge.
475    /// When true, the bridge opens a Socket Mode connection at startup.
476    #[serde(rename = "messaging:slack:enabled", default, skip_serializing_if = "is_false")]
477    pub messaging_slack_enabled: bool,
478
479    /// Slack bot token (`xoxb-...`). Used for Web API calls (chat.postMessage).
480    /// Treat as a secret — do not log. Obtain from api.slack.com/apps → OAuth & Permissions
481    /// → Bot User OAuth Token, after installing the app to the workspace.
482    #[serde(rename = "messaging:slack:bot_token", default, skip_serializing_if = "Option::is_none")]
483    pub messaging_slack_bot_token: Option<String>,
484
485    /// Slack app-level token (`xapp-...`). Used only for apps.connections.open (Socket Mode).
486    /// Treat as a secret — do not log. Obtain from api.slack.com/apps → Basic Information
487    /// → App-Level Tokens, scope `connections:write`.
488    #[serde(rename = "messaging:slack:app_token", default, skip_serializing_if = "Option::is_none")]
489    pub messaging_slack_app_token: Option<String>,
490
491    /// Channel ID to filter inbound messages and use as the default send target.
492    #[serde(rename = "messaging:slack:channel", default, skip_serializing_if = "String::is_empty")]
493    pub messaging_slack_channel: String,
494
495    /// Agent ID that receives inbound Slack messages via the reactive bus.
496    /// Absent → messages are logged but not forwarded to any agent.
497    #[serde(rename = "messaging:slack:target", default, skip_serializing_if = "Option::is_none")]
498    pub messaging_slack_target: Option<String>,
499
500    // -- WhatsApp Cloud API messaging bridge --
501    //
502    // See docs/specs/SPEC_MESSAGING_INTEGRATION_WHATSAPP_2026_07_07.md.
503    // No `messaging:whatsapp:mode` key — the spec's decision (§2.1) is
504    // Cloud API only for v1, explicitly dropping the unofficial Baileys
505    // path, so there is exactly one mode.
506
507    /// Master enable for the WhatsApp Cloud API bridge.
508    /// When true, the outbound sender starts and the `/webhook/whatsapp`
509    /// routes become live (they're always registered on the router; this
510    /// flag controls whether `WhatsAppBridge::get()` resolves).
511    #[serde(rename = "messaging:whatsapp:enabled", default, skip_serializing_if = "is_false")]
512    pub messaging_whatsapp_enabled: bool,
513
514    /// WhatsApp Business phone number ID (Meta App Dashboard > WhatsApp > API Setup).
515    #[serde(rename = "messaging:whatsapp:phone_number_id", default, skip_serializing_if = "String::is_empty")]
516    pub messaging_whatsapp_phone_number_id: String,
517
518    /// System User access token (permanent). Treat as a secret — do not log.
519    #[serde(rename = "messaging:whatsapp:access_token", default, skip_serializing_if = "Option::is_none")]
520    pub messaging_whatsapp_access_token: Option<String>,
521
522    /// Meta App Secret, used to validate X-Hub-Signature-256 on inbound
523    /// webhooks. Treat as a secret — do not log.
524    #[serde(rename = "messaging:whatsapp:app_secret", default, skip_serializing_if = "Option::is_none")]
525    pub messaging_whatsapp_app_secret: Option<String>,
526
527    /// Verify token used in the GET /webhook/whatsapp handshake. User-chosen,
528    /// must match what's entered in Meta App Dashboard > WhatsApp > Configuration.
529    /// Treat as a secret — do not log.
530    #[serde(rename = "messaging:whatsapp:webhook_verify_token", default, skip_serializing_if = "Option::is_none")]
531    pub messaging_whatsapp_webhook_verify_token: Option<String>,
532
533    /// Agent ID that receives inbound WhatsApp messages via the reactive bus.
534    /// Absent → messages are logged but not forwarded to any agent.
535    #[serde(rename = "messaging:whatsapp:target", default, skip_serializing_if = "Option::is_none")]
536    pub messaging_whatsapp_target: Option<String>,
537
538    /// Template name used for outbound sends outside the 24h customer
539    /// service window. If unset and the window has expired, send() fails
540    /// fast rather than round-tripping to the Graph API (spec §3.4).
541    #[serde(rename = "messaging:whatsapp:fallback_template", default, skip_serializing_if = "Option::is_none")]
542    pub messaging_whatsapp_fallback_template: Option<String>,
543
544    /// Template language code (BCP-47). Default "en_US" if unset.
545    #[serde(rename = "messaging:whatsapp:fallback_template_lang", default, skip_serializing_if = "Option::is_none")]
546    pub messaging_whatsapp_fallback_template_lang: Option<String>,
547
548    /// Public webhook origin (e.g. "wa.yourdomain.com") that a
549    /// user-managed tunnel (Cloudflare Tunnel, ngrok, or otherwise) points
550    /// at this instance's webhook port. **v1 does not spawn or supervise a
551    /// tunnel subprocess** — this field is used only to print the full
552    /// callback URL in the startup log as a setup reminder; the user is
553    /// responsible for standing up the tunnel and registering
554    /// `https://<this>/webhook/whatsapp` in Meta's App Dashboard themselves.
555    /// See messaging/whatsapp/mod.rs for the full scoping rationale.
556    #[serde(rename = "messaging:whatsapp:tunnel_domain", default, skip_serializing_if = "String::is_empty")]
557    pub messaging_whatsapp_tunnel_domain: String,
558
559    /// Per-connection settings: `settings.json` → `connections.<name>`
560    /// (SPEC_REMOTE_TERMINALS_AND_DURABLE_SESSIONS_2026_10_02.md §4, decision 5;
561    /// SPEC_REMOTES_PANE_2026_10_05.md §4.3). Typed here so it reaches
562    /// `FullConfigType::connections`, which every per-connection reader uses
563    /// (`ConfigState::update_settings` copies it). Before this field the object
564    /// fell into `extra` and no connection setting ever took effect.
565    /// Read entry by entry ([`deserialize_connections`]), so one bad entry
566    /// can't fail the whole file.
567    #[serde(default, skip_serializing_if = "HashMap::is_empty", deserialize_with = "deserialize_connections")]
568    pub connections: HashMap<String, ConnKeywords>,
569
570    /// Catch-all for unknown/dynamic keys (a key this struct has no field for yet).
571    /// These pass through serde unchanged so the frontend can access them as flat settings keys.
572    #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
573    pub extra: HashMap<String, serde_json::Value>,
574}
575
576// ---- Supporting config types ----
577
578/// MIME type display configuration.
579#[derive(Debug, Clone, Default, Serialize, Deserialize)]
580pub struct MimeTypeConfigType {
581    #[serde(default, skip_serializing_if = "String::is_empty")]
582    pub icon: String,
583
584    #[serde(default, skip_serializing_if = "String::is_empty")]
585    pub color: String,
586}
587
588/// File definition for block widgets.
589#[derive(Debug, Clone, Default, Serialize, Deserialize)]
590pub struct FileDef {
591    #[serde(default, skip_serializing_if = "String::is_empty")]
592    pub content: String,
593
594    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
595    pub meta: HashMap<String, Value>,
596}
597
598/// Block definition for widgets.
599#[derive(Debug, Clone, Default, Serialize, Deserialize)]
600pub struct BlockDef {
601    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
602    pub files: HashMap<String, FileDef>,
603
604    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
605    pub meta: MetaMapType,
606}
607
608/// Widget configuration.
609#[derive(Debug, Clone, Default, Serialize, Deserialize)]
610pub struct WidgetConfigType {
611    #[serde(rename = "display:order", default, skip_serializing_if = "is_zero_f64")]
612    pub display_order: f64,
613
614    /// Shown only inside its parent group's flyout, not on the bar, in More or in the pane
615    /// pickers. Not the user's Hidden (`widget:hidden`). Older files call this `display:hidden`.
616    #[serde(rename = "display:grouponly", alias = "display:hidden", default, skip_serializing_if = "is_false")]
617    pub display_grouponly: bool,
618
619    /// Whether this widget is pinned to the action bar by default on new installs.
620    /// Once the user has a `widget:pinned` setting this field is ignored.
621    #[serde(rename = "display:pinned", default, skip_serializing_if = "is_false")]
622    pub display_pinned: bool,
623
624    #[serde(default, skip_serializing_if = "String::is_empty")]
625    pub icon: String,
626
627    #[serde(default, skip_serializing_if = "String::is_empty")]
628    pub color: String,
629
630    #[serde(default, skip_serializing_if = "String::is_empty")]
631    pub label: String,
632
633    #[serde(default, skip_serializing_if = "String::is_empty")]
634    pub description: String,
635
636    #[serde(default, skip_serializing_if = "is_false")]
637    pub magnified: bool,
638
639    /// Ordered short-names (no "defwidget@" prefix, same convention as
640    /// `widget:pinned`) of this widget's children. A non-empty `children`
641    /// list is what makes a widget entry a "parent" — it expands a submenu
642    /// on the widget bar / More dropdown instead of opening a pane, so its
643    /// own `blockdef` is unused.
644    #[serde(default, skip_serializing_if = "Vec::is_empty")]
645    pub children: Vec<String>,
646
647    /// A third-party pane tab (Pane Tab contract v1, Phase 6): path to the
648    /// widget's ES module, relative to `~/.agentmux/widgets/` or absolute. The
649    /// frontend loads it (`widget-loader.ts`); `blockdef.meta.view` names its
650    /// `ext:` view. Empty for every built-in widget.
651    #[serde(default, skip_serializing_if = "String::is_empty")]
652    pub module: String,
653
654    #[serde(rename = "blockdef", default)]
655    pub block_def: BlockDef,
656}
657
658/// Terminal color theme.
659#[derive(Debug, Clone, Default, Serialize, Deserialize)]
660pub struct TermThemeType {
661    #[serde(rename = "display:name", default, skip_serializing_if = "String::is_empty")]
662    pub display_name: String,
663
664    #[serde(rename = "display:order", default, skip_serializing_if = "is_zero_f64")]
665    pub display_order: f64,
666
667    #[serde(default, skip_serializing_if = "String::is_empty")]
668    pub black: String,
669    #[serde(default, skip_serializing_if = "String::is_empty")]
670    pub red: String,
671    #[serde(default, skip_serializing_if = "String::is_empty")]
672    pub green: String,
673    #[serde(default, skip_serializing_if = "String::is_empty")]
674    pub yellow: String,
675    #[serde(default, skip_serializing_if = "String::is_empty")]
676    pub blue: String,
677    #[serde(default, skip_serializing_if = "String::is_empty")]
678    pub magenta: String,
679    #[serde(default, skip_serializing_if = "String::is_empty")]
680    pub cyan: String,
681    #[serde(default, skip_serializing_if = "String::is_empty")]
682    pub white: String,
683
684    #[serde(rename = "brightBlack", default, skip_serializing_if = "String::is_empty")]
685    pub bright_black: String,
686    #[serde(rename = "brightRed", default, skip_serializing_if = "String::is_empty")]
687    pub bright_red: String,
688    #[serde(rename = "brightGreen", default, skip_serializing_if = "String::is_empty")]
689    pub bright_green: String,
690    #[serde(rename = "brightYellow", default, skip_serializing_if = "String::is_empty")]
691    pub bright_yellow: String,
692    #[serde(rename = "brightBlue", default, skip_serializing_if = "String::is_empty")]
693    pub bright_blue: String,
694    #[serde(rename = "brightMagenta", default, skip_serializing_if = "String::is_empty")]
695    pub bright_magenta: String,
696    #[serde(rename = "brightCyan", default, skip_serializing_if = "String::is_empty")]
697    pub bright_cyan: String,
698    #[serde(rename = "brightWhite", default, skip_serializing_if = "String::is_empty")]
699    pub bright_white: String,
700
701    #[serde(default, skip_serializing_if = "String::is_empty")]
702    pub gray: String,
703    #[serde(rename = "cmdtext", default, skip_serializing_if = "String::is_empty")]
704    pub cmd_text: String,
705    #[serde(default, skip_serializing_if = "String::is_empty")]
706    pub foreground: String,
707    #[serde(rename = "selectionBackground", default, skip_serializing_if = "String::is_empty")]
708    pub selection_background: String,
709    #[serde(default, skip_serializing_if = "String::is_empty")]
710    pub background: String,
711    #[serde(default, skip_serializing_if = "String::is_empty")]
712    pub cursor: String,
713}
714
715/// Web bookmark.
716#[derive(Debug, Clone, Default, Serialize, Deserialize)]
717pub struct WebBookmark {
718    #[serde(default)]
719    pub url: String,
720
721    #[serde(default, skip_serializing_if = "String::is_empty")]
722    pub title: String,
723
724    #[serde(default, skip_serializing_if = "String::is_empty")]
725    pub icon: String,
726
727    #[serde(rename = "iconcolor", default, skip_serializing_if = "String::is_empty")]
728    pub icon_color: String,
729
730    #[serde(rename = "iconurl", default, skip_serializing_if = "String::is_empty")]
731    pub icon_url: String,
732
733    #[serde(rename = "display:order", default, skip_serializing_if = "is_zero_f64")]
734    pub display_order: f64,
735}
736
737/// Per-connection configuration keywords.
738/// Matches Go's `wconfig.ConnKeywords`.
739#[derive(Debug, Clone, Default, Serialize, Deserialize)]
740pub struct ConnKeywords {
741    // -- Connection settings --
742    #[serde(rename = "conn:shellpath", default, skip_serializing_if = "String::is_empty")]
743    pub conn_shell_path: String,
744
745    #[serde(rename = "conn:ignoresshconfig", default, skip_serializing_if = "Option::is_none")]
746    pub conn_ignore_ssh_config: Option<bool>,
747
748    /// Installing AgentMux's helper on this host: `ask` (unset), `always` or
749    /// `never` (SPEC_REMOTES_PANE_2026_10_05.md §4.9).
750    #[serde(rename = "conn:helper", default, skip_serializing_if = "String::is_empty")]
751    pub conn_helper: String,
752
753    // -- Display settings --
754    /// A nickname shown instead of the connection name (Remotes, the picker).
755    #[serde(rename = "display:name", default, skip_serializing_if = "String::is_empty")]
756    pub display_name: String,
757
758    /// A colour for the remote, `#rrggbb` (SPEC_REMOTES_PANE_2026_10_05.md §4.8).
759    #[serde(rename = "display:color", default, skip_serializing_if = "String::is_empty")]
760    pub display_color: String,
761
762    /// Pinned to the top of Remotes and the picker.
763    #[serde(rename = "display:pinned", default, skip_serializing_if = "Option::is_none")]
764    pub display_pinned: Option<bool>,
765
766    #[serde(rename = "display:hidden", default, skip_serializing_if = "Option::is_none")]
767    pub display_hidden: Option<bool>,
768
769    #[serde(rename = "display:order", default, skip_serializing_if = "is_zero_f32")]
770    pub display_order: f32,
771
772    // -- Terminal settings --
773    #[serde(rename = "term:*", default, skip_serializing_if = "is_false")]
774    pub term_clear: bool,
775
776    #[serde(rename = "term:fontsize", default, skip_serializing_if = "is_zero_f64")]
777    pub term_font_size: f64,
778
779    #[serde(rename = "term:fontfamily", default, skip_serializing_if = "String::is_empty")]
780    pub term_font_family: String,
781
782    #[serde(rename = "term:theme", default, skip_serializing_if = "String::is_empty")]
783    pub term_theme: String,
784
785    /// This connection's panes durable or not (over the global `term:durable`).
786    #[serde(rename = "term:durable", default, skip_serializing_if = "Option::is_none")]
787    pub term_durable: Option<bool>,
788
789    // -- Command settings --
790    #[serde(rename = "cmd:env", default, skip_serializing_if = "HashMap::is_empty")]
791    pub cmd_env: HashMap<String, String>,
792
793    #[serde(rename = "cmd:initscript", default, skip_serializing_if = "String::is_empty")]
794    pub cmd_init_script: String,
795
796    #[serde(rename = "cmd:initscript.sh", default, skip_serializing_if = "String::is_empty")]
797    pub cmd_init_script_sh: String,
798
799    #[serde(rename = "cmd:initscript.bash", default, skip_serializing_if = "String::is_empty")]
800    pub cmd_init_script_bash: String,
801
802    #[serde(rename = "cmd:initscript.zsh", default, skip_serializing_if = "String::is_empty")]
803    pub cmd_init_script_zsh: String,
804
805    #[serde(rename = "cmd:initscript.pwsh", default, skip_serializing_if = "String::is_empty")]
806    pub cmd_init_script_pwsh: String,
807
808    #[serde(rename = "cmd:initscript.fish", default, skip_serializing_if = "String::is_empty")]
809    pub cmd_init_script_fish: String,
810
811    // -- SSH settings --
812    #[serde(rename = "ssh:user", default, skip_serializing_if = "Option::is_none")]
813    pub ssh_user: Option<String>,
814
815    #[serde(rename = "ssh:hostname", default, skip_serializing_if = "Option::is_none")]
816    pub ssh_hostname: Option<String>,
817
818    #[serde(rename = "ssh:port", default, skip_serializing_if = "Option::is_none")]
819    pub ssh_port: Option<String>,
820
821    #[serde(rename = "ssh:identityfile", default, skip_serializing_if = "Vec::is_empty")]
822    pub ssh_identity_file: Vec<String>,
823
824    #[serde(rename = "ssh:batchmode", default, skip_serializing_if = "Option::is_none")]
825    pub ssh_batch_mode: Option<bool>,
826
827    #[serde(rename = "ssh:pubkeyauthentication", default, skip_serializing_if = "Option::is_none")]
828    pub ssh_pubkey_authentication: Option<bool>,
829
830    #[serde(rename = "ssh:passwordauthentication", default, skip_serializing_if = "Option::is_none")]
831    pub ssh_password_authentication: Option<bool>,
832
833    #[serde(rename = "ssh:kbdinteractiveauthentication", default, skip_serializing_if = "Option::is_none")]
834    pub ssh_kbd_interactive_authentication: Option<bool>,
835
836    #[serde(rename = "ssh:preferredauthentications", default, skip_serializing_if = "Vec::is_empty")]
837    pub ssh_preferred_authentications: Vec<String>,
838
839    #[serde(rename = "ssh:addkeystoagent", default, skip_serializing_if = "Option::is_none")]
840    pub ssh_add_keys_to_agent: Option<bool>,
841
842    #[serde(rename = "ssh:identityagent", default, skip_serializing_if = "Option::is_none")]
843    pub ssh_identity_agent: Option<String>,
844
845    #[serde(rename = "ssh:identitiesonly", default, skip_serializing_if = "Option::is_none")]
846    pub ssh_identities_only: Option<bool>,
847
848    #[serde(rename = "ssh:proxyjump", default, skip_serializing_if = "Vec::is_empty")]
849    pub ssh_proxy_jump: Vec<String>,
850
851    #[serde(rename = "ssh:userknownhostsfile", default, skip_serializing_if = "Vec::is_empty")]
852    pub ssh_user_known_hosts_file: Vec<String>,
853
854    #[serde(rename = "ssh:globalknownhostsfile", default, skip_serializing_if = "Vec::is_empty")]
855    pub ssh_global_known_hosts_file: Vec<String>,
856}
857
858/// Configuration error from parsing.
859#[derive(Debug, Clone, Default, Serialize, Deserialize)]
860pub struct ConfigError {
861    pub file: String,
862    pub err: String,
863}
864
865/// Webhook integration configuration.
866#[allow(dead_code)]
867#[derive(Debug, Clone, Default, Serialize, Deserialize)]
868pub struct WebhookConfigType {
869    #[serde(default)]
870    pub version: String,
871
872    #[serde(rename = "workspaceId", default)]
873    pub workspace_id: String,
874
875    #[serde(rename = "authToken", default)]
876    pub auth_token: String,
877
878    #[serde(rename = "cloudEndpoint", default)]
879    pub cloud_endpoint: String,
880
881    #[serde(default)]
882    pub enabled: bool,
883
884    #[serde(default)]
885    pub terminals: Vec<String>,
886}
887
888// ---- Full config container ----
889
890/// Complete application configuration.
891/// Matches Go's `wconfig.FullConfigType`.
892#[derive(Debug, Clone, Default, Serialize, Deserialize)]
893pub struct FullConfigType {
894    #[serde(default)]
895    pub settings: SettingsType,
896
897    #[serde(rename = "mimetypes", default)]
898    pub mime_types: HashMap<String, MimeTypeConfigType>,
899
900    #[serde(default)]
901    pub widgets: HashMap<String, WidgetConfigType>,
902
903    #[serde(default)]
904    pub presets: HashMap<String, MetaMapType>,
905
906    #[serde(rename = "termthemes", default)]
907    pub term_themes: HashMap<String, TermThemeType>,
908
909    #[serde(default)]
910    pub connections: HashMap<String, ConnKeywords>,
911
912    #[serde(default)]
913    pub bookmarks: HashMap<String, WebBookmark>,
914
915    #[serde(rename = "configerrors", default, skip_serializing_if = "Vec::is_empty")]
916    pub config_errors: Vec<ConfigError>,
917
918    /// The browser pane's configured start page — `None` until the user
919    /// sets one via the bookmarks menu's "Set as Start Page" row. Global
920    /// across channels (`~/.agentmux/shared/browser-start-page.json`, NOT
921    /// `settings.json` — see that file's own module doc comment), and
922    /// deliberately living here rather than behind its own RPC so the
923    /// frontend gets it for free from the same `GetFullConfig` call it
924    /// already awaits before rendering, with live cross-window updates via
925    /// the same file-watch/broadcast pipeline `settings` itself uses. See
926    /// `docs/specs/SPEC_BROWSER_PANE_START_PAGE_2026_09_16.md`.
927    #[serde(rename = "browserstartpage", default, skip_serializing_if = "Option::is_none")]
928    pub browser_start_page: Option<String>,
929}
930
931#[cfg(test)]
932mod statusbar_muxbus_cloud_tests {
933    use super::SettingsType;
934
935    // SPEC_STATUSBAR_HIDE_MUXBUS_SETTING_2026_10_08.md: absent means shown, so
936    // only an explicit false is ever stored, and the default writes nothing.
937    #[test]
938    fn absent_by_default_and_not_serialised() {
939        let s = SettingsType::default();
940        assert_eq!(s.statusbar_show_muxbus_cloud, None);
941        let json = serde_json::to_string(&s).unwrap();
942        assert!(!json.contains("statusbar:showmuxbuscloud"), "{json}");
943    }
944
945    #[test]
946    fn explicit_false_round_trips() {
947        let s: SettingsType = serde_json::from_str(r#"{"statusbar:showmuxbuscloud": false}"#).unwrap();
948        assert_eq!(s.statusbar_show_muxbus_cloud, Some(false));
949        let json = serde_json::to_string(&s).unwrap();
950        assert!(json.contains(r#""statusbar:showmuxbuscloud":false"#), "{json}");
951    }
952
953    #[test]
954    fn explicit_true_round_trips() {
955        let s: SettingsType = serde_json::from_str(r#"{"statusbar:showmuxbuscloud": true}"#).unwrap();
956        assert_eq!(s.statusbar_show_muxbus_cloud, Some(true));
957    }
958}