mars-terminal 0.5.0

A terminal editor, multiplexer, and built-in AI agent in one binary — non-modal and Emacs-compatible, with tmux-style persistent sessions.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
/// Behavioral tuning knobs — loaded from ~/.config/mars/tuning.json.
///
/// Every knob is stored as `{ "value": ..., "description": "..." }` so that a
/// human or an agent editing the file can see what each number does. Defaults
/// are written on first run; user values are layered over defaults, so new
/// knobs appear in old files and unknown keys are ignored.

use std::collections::HashMap;

use serde::{Deserialize, Serialize};

#[derive(Clone)]
pub struct Tuning {
    pub poll_interval_ms: u64,
    pub which_key_delay_ms: u64,
    pub nudge_threshold: u32,
    pub max_panes: usize,
    pub scroll_margin: usize,
    pub page_overlap: usize,
    pub wheel_scroll_lines: usize,
    pub dropdown_max_rows: u16,
    pub panel_max_height_pct: u16,
    pub ask_panel_max_pct: u16,
    pub spinner_speed_ticks: u64,
    pub which_key_panel_width: u16,
    pub travel_panel_width: u16,
    pub binding_badge_width: usize,
    pub selection_bg: [u8; 3],
    pub search_match_bg: [u8; 3],
    // ── Theme (Mars palette: Claude-Code clay + rust) ──
    pub theme_accent: [u8; 3],
    pub theme_accent_bright: [u8; 3],
    pub theme_accent_dark: [u8; 3],
    pub theme_chip_fg: [u8; 3],
    pub theme_terminal: [u8; 3],
    pub theme_healthy: [u8; 3],
    pub theme_status_failed: [u8; 3],
    pub theme_status_blocked: [u8; 3],
    /// Show the line-number gutter in editor panes (position always lives in
    /// the status bar).
    pub line_numbers: bool,
    /// Seconds before the agent may auto-name a default-named tab (0 = off).
    pub auto_name_secs: u64,
    pub agent_max_tokens: u32,
    pub agent_temperature: f64,
    pub terminal_default_rows: u16,
    pub terminal_default_cols: u16,
    pub terminal_scrollback_lines: usize,
    pub terminal_startup_probe_ms: u64,
    pub autosave_secs: u64,
    pub project_index_max: usize,
    pub project_ignore: Vec<String>,
    pub tree_width: u16,
    pub tree_show_dotfiles: u64,
    pub watch_quiet_secs: u64,
    pub agent_scrollback_context: usize,
    pub memory_cwd_boost: f64,
    pub memory_recency_boost: f64,
    pub memory_recency_halflife_days: f64,
    pub mission_refresh_secs: u64,
    pub worklog_max_lines: u64,
    pub mission_briefing: u64,
    pub mission_briefing_animate: u64,
    pub mission_briefing_type_ms: u64,
    pub mission_briefing_prose_rows: u64,
    pub auto_watch: u64,
    pub watch_min_active_secs: u64,
    pub goal_tracking: u64,
}

impl Default for Tuning {
    fn default() -> Self {
        Tuning {
            poll_interval_ms: 16,
            which_key_delay_ms: 200,
            nudge_threshold: 3,
            max_panes: 4,
            scroll_margin: 3,
            page_overlap: 2,
            wheel_scroll_lines: 3,
            dropdown_max_rows: 20,
            panel_max_height_pct: 60,
            ask_panel_max_pct: 30,
            spinner_speed_ticks: 3,
            which_key_panel_width: 30,
            travel_panel_width: 46,
            binding_badge_width: 9,
            selection_bg: [74, 42, 31],      // deep rust-brown
            search_match_bg: [138, 84, 20],  // amber
            theme_accent: [217, 119, 87],        // #D97757 terracotta/clay
            theme_accent_bright: [233, 161, 120], // #E9A178 light sand
            theme_accent_dark: [183, 65, 14],     // #B7410E rust
            theme_chip_fg: [31, 20, 16],          // dark text on accent chips
            theme_terminal: [13, 115, 119],       // #0D7377 dark teal
            theme_healthy: [61, 174, 114],        // #3DAE72 healthy green (working/running)
            theme_status_failed: [217, 83, 79],   // #D9534F conventional error red (failed)
            theme_status_blocked: [230, 165, 60], // #E6A53C conventional amber (blocked / waiting on you)
            line_numbers: false,
            auto_name_secs: 45,
            agent_max_tokens: 1024, // headroom for reasoning models (Qwen3/R1)
            agent_temperature: 0.3,
            terminal_default_rows: 24,
            terminal_default_cols: 80,
            terminal_scrollback_lines: 10_000,
            terminal_startup_probe_ms: 5000,
            autosave_secs: 30,
            project_index_max: 20_000,
            project_ignore: ["target", "node_modules", ".git", "dist", "build", ".venv"]
                .iter()
                .map(|s| s.to_string())
                .collect(),
            tree_width: 30,
            tree_show_dotfiles: 0,
            watch_quiet_secs: 20,
            agent_scrollback_context: 200,
            memory_cwd_boost: 0.25,
            memory_recency_boost: 0.15,
            memory_recency_halflife_days: 14.0,
            mission_refresh_secs: 600,
            worklog_max_lines: 4000,
            mission_briefing: 2,
            mission_briefing_animate: 1,
            mission_briefing_type_ms: 13,
            mission_briefing_prose_rows: 13,
            auto_watch: 1,
            watch_min_active_secs: 10,
            goal_tracking: 1,
        }
    }
}

impl Tuning {
    /// which-key delay expressed in frame ticks of the main loop.
    pub fn which_key_delay_ticks(&self) -> u64 {
        (self.which_key_delay_ms / self.poll_interval_ms.max(1)).max(1)
    }
}

// ── File format ───────────────────────────────────────────────────────────────

#[derive(Serialize, Deserialize)]
struct Knob {
    value: serde_json::Value,
    description: String,
}

fn knob(value: serde_json::Value, description: &str) -> Knob {
    Knob { value, description: description.to_string() }
}

/// The tunable knobs as self-contained, actionable retrieval lines — each names
/// the knob, WHERE to set it, and the default — so the agent answers
/// self-reconfiguration questions with the exact `knob = value in tuning.json`
/// rather than hallucinating the file or the knob name.
#[cfg_attr(not(feature = "memory"), allow(dead_code))] // sole consumer is the docs corpus
pub fn knob_descriptions() -> Vec<String> {
    default_knobs()
        .into_iter()
        .map(|(name, k)| {
            format!(
                "To change {}: set `{name}` in ~/.config/mars/tuning.json (default {}).",
                k.description, k.value
            )
        })
        .collect()
}

/// The default knob map, with the semantic explanations that make the file
/// safely editable by a human or an agent.
fn default_knobs() -> Vec<(&'static str, Knob)> {
    use serde_json::json;
    let d = Tuning::default();
    vec![
        ("poll_interval_ms", knob(json!(d.poll_interval_ms),
            "Main loop tick in milliseconds. Lower = snappier UI + spinner, higher CPU.")),
        ("which_key_delay_ms", knob(json!(d.which_key_delay_ms),
            "Hesitation on a prefix (C-x…) before the which-key panel pops. \
             Lower teaches eagerly; higher keeps it invisible to fast typists.")),
        ("nudge_threshold", knob(json!(d.nudge_threshold),
            "How many times an action is run from the command bar before the \
             '💡 next time: <key>' graduation hint appears in the status line.")),
        ("max_panes", knob(json!(d.max_panes),
            "Maximum panes per tab. Splits beyond this are refused.")),
        ("scroll_margin", knob(json!(d.scroll_margin),
            "Lines kept visible above/below the cursor when scrolling.")),
        ("page_overlap", knob(json!(d.page_overlap),
            "Lines of overlap kept on PageUp/PageDown so context isn't lost.")),
        ("wheel_scroll_lines", knob(json!(d.wheel_scroll_lines),
            "Lines moved per mouse-wheel step.")),
        ("dropdown_max_rows", knob(json!(d.dropdown_max_rows),
            "Maximum visible rows in the command-bar dropdown.")),
        ("panel_max_height_pct", knob(json!(d.panel_max_height_pct),
            "Maximum height of pop-up panels (dropdown) as % of the editor area.")),
        ("ask_panel_max_pct", knob(json!(d.ask_panel_max_pct),
            "Maximum height of the ask/chat panel as % of the workspace — it hugs the bottom; scroll up (Up key or wheel) for older turns.")),
        ("spinner_speed_ticks", knob(json!(d.spinner_speed_ticks),
            "Frame ticks per spinner animation step while the agent thinks. Lower = faster spin.")),
        ("which_key_panel_width", knob(json!(d.which_key_panel_width),
            "Width (columns) of the which-key continuation panel.")),
        ("travel_panel_width", knob(json!(d.travel_panel_width),
            "Width (columns) of the C-t travel-mode cheat panel.")),
        ("binding_badge_width", knob(json!(d.binding_badge_width),
            "Column width reserved for keybinding badges in the dropdown.")),
        ("selection_bg", knob(json!(d.selection_bg),
            "RGB background of the active selection highlight.")),
        ("search_match_bg", knob(json!(d.search_match_bg),
            "RGB background of isearch match highlights.")),
        ("theme_accent", knob(json!(d.theme_accent),
            "RGB brand accent (Mars terracotta): focused borders, active tab, \
             command bar, selected rows, EDIT chip.")),
        ("theme_accent_bright", knob(json!(d.theme_accent_bright),
            "RGB bright accent (sand): which-key keys, keybinding badges, \
             teaching surfaces.")),
        ("theme_accent_dark", knob(json!(d.theme_accent_dark),
            "RGB dark accent (rust): splash gradient, secondary emphasis.")),
        ("theme_chip_fg", knob(json!(d.theme_chip_fg),
            "RGB text color on accent-colored chips/badges.")),
        ("theme_terminal", knob(json!(d.theme_terminal),
            "RGB for live terminal panes: focused border + TERM mode chip.")),
        ("theme_healthy", knob(json!(d.theme_healthy),
            "RGB for a HEALTHY / running surface in the workspace monitor and \
             briefing — a calm green you can dismiss at a glance, distinct from \
             teal (done/win) and the warm danger hues.")),
        ("theme_status_failed", knob(json!(d.theme_status_failed),
            "RGB for a FAILED surface (✗) — conventional error red, the loudest \
             status hue.")),
        ("theme_status_blocked", knob(json!(d.theme_status_blocked),
            "RGB for a BLOCKED surface (⏸) waiting on your input — conventional \
             amber, distinct from the red of an outright failure.")),
        ("line_numbers", knob(json!(d.line_numbers),
            "Show the line-number gutter in editor panes. The cursor position \
             is always in the status bar, so this defaults to off for width.")),
        ("auto_name_secs", knob(json!(d.auto_name_secs),
            "With an agent configured, tabs still wearing their default numeric \
             name get an auto-generated label after this many seconds (0 = off). \
             Renaming a tab yourself always wins and opts it out.")),
        ("agent_max_tokens", knob(json!(d.agent_max_tokens),
            "Max tokens the ask-agent may generate per answer.")),
        ("agent_temperature", knob(json!(d.agent_temperature),
            "Sampling temperature for the ask-agent. Lower = more deterministic.")),
        ("terminal_default_rows", knob(json!(d.terminal_default_rows),
            "Initial PTY rows before the first render sizes the terminal pane.")),
        ("terminal_default_cols", knob(json!(d.terminal_default_cols),
            "Initial PTY columns before the first render sizes the terminal pane.")),
        ("terminal_scrollback_lines", knob(json!(d.terminal_scrollback_lines),
            "Scrollback history kept per terminal pane. Scroll with the wheel or \
             Shift+PageUp/PageDown; any keystroke snaps back to live.")),
        ("terminal_startup_probe_ms", knob(json!(d.terminal_startup_probe_ms),
            "Delay and retry interval for the readiness probe used when a fresh \
             shell has an empty or unrecognized prompt. Commands and typing stay \
             buffered until the shell accepts the probe.")),
        ("autosave_secs", knob(json!(d.autosave_secs),
            "Seconds between silent autosaves of modified buffers that have a file \
             path (also fires on session detach/disconnect). 0 disables.")),
        ("project_index_max", knob(json!(d.project_index_max),
            "Max files the `@` picker indexes from the project (bounds memory on huge \
             trees).")),
        ("project_ignore", knob(json!(d.project_ignore),
            "Directory names the file index/tree skip (plus all dotdirs). Does not yet \
             read a repo's .gitignore.")),
        ("tree_width", knob(json!(d.tree_width),
            "Column width of the left file-tree sidebar (@ / C-x d).")),
        ("tree_show_dotfiles", knob(json!(d.tree_show_dotfiles),
            "Show dotfiles in the navigator by default (0/1). `.` toggles at runtime; \
             the project_ignore list (.git, .venv, …) stays hidden regardless.")),
        ("watch_quiet_secs", knob(json!(d.watch_quiet_secs),
            "Seconds a watched terminal (C-t w) must be silent before Mars summarizes it \
             (W6). Also fires immediately on process exit. Generous by design — a false \
             'done' costs more than the feature earns.")),
        ("agent_scrollback_context", knob(json!(d.agent_scrollback_context),
            "Lines of a watched/focused terminal's screen sent to the agent for a summary \
             or triage.")),
        ("memory_cwd_boost", knob(json!(d.memory_cwd_boost),
            "How much a remembered command from the CURRENT working directory outranks a \
             lexical tie from elsewhere (0 = off). Same-project memories answer \
             project-specific requests.")),
        ("memory_recency_boost", knob(json!(d.memory_recency_boost),
            "How much a RECENT remembered command outranks a lexical tie from long ago \
             (0 = off); decays with memory_recency_halflife_days.")),
        ("memory_recency_halflife_days", knob(json!(d.memory_recency_halflife_days),
            "Days for the recency boost to halve. Smaller = the agent prefers this \
             week's habits; larger = long memory.")),
        ("mission_refresh_secs", knob(json!(d.mission_refresh_secs),
            "How often (at most) the agent re-infers your one-line mission from the \
             work journal of watch verdicts; shown by `mars ls`. 0 disables.")),
        ("worklog_max_lines", knob(json!(d.worklog_max_lines),
            "Work-journal size bound (~/.mars/worklog.jsonl): past twice this many \
             lines it is compacted to the newest this-many at startup. 0 = never.")),
        ("mission_briefing", knob(json!(d.mission_briefing),
            "What reattach shows: 2 = the full-screen Mission Briefing (a centered, \
             plain-English summary of what happened while you were away — any key \
             resumes), 1 = a one-line notice, 0 = nothing.")),
        ("mission_briefing_animate", knob(json!(d.mission_briefing_animate),
            "1 = the Mission Briefing boots up on reattach (elements reveal in ~0.4s, \
             failures first); 0 = it appears instantly (thin SSH links, reduced motion).")),
        ("mission_briefing_type_ms", knob(json!(d.mission_briefing_type_ms),
            "Milliseconds per character as the briefing prose types itself in behind a \
             cursor (animate=1 only). Higher is slower; e.g. 13 ≈ 75 chars/sec.")),
        ("mission_briefing_prose_rows", knob(json!(d.mission_briefing_prose_rows),
            "Rows reserved for the briefing prose so the layout is a fixed vessel the \
             text fills top-down — the manifest below never shifts as prose streams. \
             Raise it if long briefings push the systems board down.")),
        ("auto_watch", knob(json!(d.auto_watch),
            "1 = panes that stay busy past watch_min_active_secs are watched \
             automatically (verdicts without arming a watch); 0 = only C-x w \
             watches.")),
        ("watch_min_active_secs", knob(json!(d.watch_min_active_secs),
            "Seconds of continuous output before auto-watch arms a pane — \
             filters one-shot commands so only real runs earn verdicts.")),
        ("goal_tracking", knob(json!(d.goal_tracking),
            "1 = when you detach, the agent captures what you were working \
             toward, so the reattach briefing can report progress on it; \
             0 = off.")),
    ]
}

// ── load() ────────────────────────────────────────────────────────────────────

pub fn tuning_path() -> Option<std::path::PathBuf> {
    crate::config::state_path().map(|p| p.with_file_name("tuning.json"))
}

/// Write the annotated default knobs to `path` (used on first run and reset).
fn write_default_knobs(path: &std::path::Path) {
    if let Some(parent) = path.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    let map: serde_json::Map<String, serde_json::Value> = default_knobs()
        .into_iter()
        .map(|(k, v)| (k.to_string(), serde_json::to_value(v).unwrap()))
        .collect();
    if let Ok(json) = serde_json::to_string_pretty(&map) {
        let _ = std::fs::write(path, json);
    }
}

/// Restore tuning.json to defaults, backing up the current file.
pub fn reset() {
    if let Some(p) = tuning_path() {
        if p.exists() {
            let _ = std::fs::rename(&p, p.with_extension("json.bak"));
        }
        write_default_knobs(&p);
    }
}

pub fn load() -> Tuning {
    let path = tuning_path();
    let user: Option<HashMap<String, Knob>> = path
        .as_ref()
        .and_then(|p| std::fs::read_to_string(p).ok())
        .and_then(|s| serde_json::from_str(&s).ok());

    if user.is_none() {
        // First run: write the annotated defaults so they're discoverable/editable.
        if let Some(p) = &path {
            write_default_knobs(p);
        }
    }

    let mut t = Tuning::default();
    if let Some(map) = user {
        let get_u64 = |m: &HashMap<String, Knob>, k: &str, d: u64| {
            m.get(k).and_then(|e| e.value.as_u64()).unwrap_or(d)
        };
        let get_f64 = |m: &HashMap<String, Knob>, k: &str, d: f64| {
            m.get(k).and_then(|e| e.value.as_f64()).unwrap_or(d)
        };
        let get_rgb = |m: &HashMap<String, Knob>, k: &str, d: [u8; 3]| {
            m.get(k)
                .and_then(|e| e.value.as_array())
                .and_then(|a| {
                    if a.len() == 3 {
                        Some([a[0].as_u64()? as u8, a[1].as_u64()? as u8, a[2].as_u64()? as u8])
                    } else {
                        None
                    }
                })
                .unwrap_or(d)
        };
        t.poll_interval_ms      = get_u64(&map, "poll_interval_ms", t.poll_interval_ms).max(1);
        t.which_key_delay_ms    = get_u64(&map, "which_key_delay_ms", t.which_key_delay_ms);
        t.nudge_threshold       = get_u64(&map, "nudge_threshold", t.nudge_threshold as u64) as u32;
        t.max_panes             = get_u64(&map, "max_panes", t.max_panes as u64) as usize;
        t.scroll_margin         = get_u64(&map, "scroll_margin", t.scroll_margin as u64) as usize;
        t.page_overlap          = get_u64(&map, "page_overlap", t.page_overlap as u64) as usize;
        t.wheel_scroll_lines    = get_u64(&map, "wheel_scroll_lines", t.wheel_scroll_lines as u64) as usize;
        t.dropdown_max_rows     = get_u64(&map, "dropdown_max_rows", t.dropdown_max_rows as u64) as u16;
        t.panel_max_height_pct  = get_u64(&map, "panel_max_height_pct", t.panel_max_height_pct as u64) as u16;
        t.ask_panel_max_pct     = get_u64(&map, "ask_panel_max_pct", t.ask_panel_max_pct as u64) as u16;
        t.spinner_speed_ticks   = get_u64(&map, "spinner_speed_ticks", t.spinner_speed_ticks).max(1);
        t.which_key_panel_width = get_u64(&map, "which_key_panel_width", t.which_key_panel_width as u64) as u16;
        t.travel_panel_width    = get_u64(&map, "travel_panel_width", t.travel_panel_width as u64) as u16;
        t.binding_badge_width   = get_u64(&map, "binding_badge_width", t.binding_badge_width as u64) as usize;
        t.selection_bg          = get_rgb(&map, "selection_bg", t.selection_bg);
        t.search_match_bg       = get_rgb(&map, "search_match_bg", t.search_match_bg);
        t.theme_accent          = get_rgb(&map, "theme_accent", t.theme_accent);
        t.theme_accent_bright   = get_rgb(&map, "theme_accent_bright", t.theme_accent_bright);
        t.theme_accent_dark     = get_rgb(&map, "theme_accent_dark", t.theme_accent_dark);
        t.theme_chip_fg         = get_rgb(&map, "theme_chip_fg", t.theme_chip_fg);
        t.theme_terminal        = get_rgb(&map, "theme_terminal", t.theme_terminal);
        t.theme_healthy         = get_rgb(&map, "theme_healthy", t.theme_healthy);
        t.theme_status_failed   = get_rgb(&map, "theme_status_failed", t.theme_status_failed);
        t.theme_status_blocked  = get_rgb(&map, "theme_status_blocked", t.theme_status_blocked);
        t.line_numbers = map
            .get("line_numbers")
            .and_then(|e| e.value.as_bool())
            .unwrap_or(t.line_numbers);
        t.auto_name_secs = get_u64(&map, "auto_name_secs", t.auto_name_secs);
        t.agent_max_tokens      = get_u64(&map, "agent_max_tokens", t.agent_max_tokens as u64) as u32;
        t.agent_temperature     = get_f64(&map, "agent_temperature", t.agent_temperature);
        t.terminal_default_rows = get_u64(&map, "terminal_default_rows", t.terminal_default_rows as u64) as u16;
        t.terminal_default_cols = get_u64(&map, "terminal_default_cols", t.terminal_default_cols as u64) as u16;
        t.terminal_scrollback_lines =
            get_u64(&map, "terminal_scrollback_lines", t.terminal_scrollback_lines as u64) as usize;
        t.terminal_startup_probe_ms =
            get_u64(&map, "terminal_startup_probe_ms", t.terminal_startup_probe_ms).max(1);
        t.autosave_secs = get_u64(&map, "autosave_secs", t.autosave_secs);
        t.project_index_max = get_u64(&map, "project_index_max", t.project_index_max as u64) as usize;
        t.tree_width = get_u64(&map, "tree_width", t.tree_width as u64) as u16;
        t.tree_show_dotfiles = get_u64(&map, "tree_show_dotfiles", t.tree_show_dotfiles);
        t.watch_quiet_secs = get_u64(&map, "watch_quiet_secs", t.watch_quiet_secs);
        t.agent_scrollback_context =
            get_u64(&map, "agent_scrollback_context", t.agent_scrollback_context as u64) as usize;
        t.memory_cwd_boost = get_f64(&map, "memory_cwd_boost", t.memory_cwd_boost);
        t.memory_recency_boost = get_f64(&map, "memory_recency_boost", t.memory_recency_boost);
        t.memory_recency_halflife_days =
            get_f64(&map, "memory_recency_halflife_days", t.memory_recency_halflife_days);
        t.mission_refresh_secs = get_u64(&map, "mission_refresh_secs", t.mission_refresh_secs);
        t.worklog_max_lines = get_u64(&map, "worklog_max_lines", t.worklog_max_lines);
        // Renamed from `shift_report`; honor the old key if a config predates it.
        t.mission_briefing =
            get_u64(&map, "mission_briefing", get_u64(&map, "shift_report", t.mission_briefing));
        t.mission_briefing_animate =
            get_u64(&map, "mission_briefing_animate", t.mission_briefing_animate);
        t.mission_briefing_type_ms =
            get_u64(&map, "mission_briefing_type_ms", t.mission_briefing_type_ms);
        t.mission_briefing_prose_rows =
            get_u64(&map, "mission_briefing_prose_rows", t.mission_briefing_prose_rows);
        t.auto_watch = get_u64(&map, "auto_watch", t.auto_watch);
        t.watch_min_active_secs = get_u64(&map, "watch_min_active_secs", t.watch_min_active_secs);
        t.goal_tracking = get_u64(&map, "goal_tracking", t.goal_tracking);
        if let Some(list) = map.get("project_ignore").and_then(|e| e.value.as_array()) {
            let dirs: Vec<String> =
                list.iter().filter_map(|v| v.as_str().map(String::from)).collect();
            if !dirs.is_empty() {
                t.project_ignore = dirs;
            }
        }
    }
    t
}