Skip to main content

seher/web/
mod.rs

1//! Web-based configuration editor served at a local HTTP port.
2//!
3//! Start with `seher --gui-config`. A browser window opens automatically.
4//! Changes are held in memory until "Save to Disk" is clicked.
5
6#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
7
8use axum::{
9    Router,
10    extract::{Form, Path, Query, State},
11    http::StatusCode,
12    response::Html,
13    routing::{get, post},
14};
15use std::collections::{BTreeSet, HashMap};
16use std::fmt::Write as _;
17use std::path::PathBuf;
18use std::sync::{Arc, Mutex};
19use tokio::net::TcpListener;
20
21use chrono::Local;
22
23use crate::config::{AgentConfig, ProviderConfig, Settings};
24
25// -- shared state --------------------------------------------------------------
26
27struct AppState {
28    settings: Mutex<Settings>,
29    config_path: Option<PathBuf>,
30}
31
32type SharedState = Arc<AppState>;
33type HandlerResult = Result<Html<String>, (StatusCode, String)>;
34
35fn lock_settings(
36    state: &AppState,
37) -> Result<std::sync::MutexGuard<'_, Settings>, (StatusCode, String)> {
38    state
39        .settings
40        .lock()
41        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
42}
43
44// -- helpers -------------------------------------------------------------------
45
46/// Union of all models-map keys across agents, sorted, with "(none)" appended.
47fn collect_model_keys(settings: &Settings) -> Vec<String> {
48    let mut keys: BTreeSet<String> = BTreeSet::new();
49    for agent in &settings.agents {
50        if let Some(models) = &agent.models {
51            for key in models.keys() {
52                keys.insert(key.clone());
53            }
54        }
55    }
56    for rule in &settings.priority {
57        if let Some(model) = &rule.model {
58            keys.insert(model.clone());
59        }
60    }
61    let mut result: Vec<String> = keys.into_iter().collect();
62    result.push("(none)".to_string());
63    result
64}
65
66/// Priority of agent x model. Returns `None` when the model is unavailable.
67fn priority_value(
68    settings: &Settings,
69    agent: &AgentConfig,
70    model_key: &str,
71    now: &chrono::DateTime<Local>,
72) -> Option<i32> {
73    if model_key == "(none)" {
74        return Some(settings.priority_for_at(agent, None, now));
75    }
76    match &agent.models {
77        Some(models) if models.contains_key(model_key) => {
78            Some(settings.priority_for_at(agent, Some(model_key), now))
79        }
80        Some(_) => None,
81        None => Some(settings.priority_for_at(agent, Some(model_key), now)), // passthrough
82    }
83}
84
85fn percent_encode_query(s: &str) -> String {
86    let mut result = String::with_capacity(s.len());
87    for byte in s.bytes() {
88        match byte {
89            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
90                result.push(byte as char);
91            }
92            b => {
93                let _ = write!(result, "%{b:02X}");
94            }
95        }
96    }
97    result
98}
99
100fn escape_html(s: &str) -> String {
101    s.replace('&', "&amp;")
102        .replace('<', "&lt;")
103        .replace('>', "&gt;")
104        .replace('"', "&quot;")
105}
106
107fn fmt_vec(v: &[String]) -> String {
108    v.join("\n")
109}
110
111fn fmt_map(m: &HashMap<String, String>) -> String {
112    let mut pairs: Vec<String> = m.iter().map(|(k, v)| format!("{k}={v}")).collect();
113    pairs.sort();
114    pairs.join("\n")
115}
116
117fn fmt_arg_maps(m: &HashMap<String, Vec<String>>) -> String {
118    let mut pairs: Vec<String> = m
119        .iter()
120        .map(|(k, v)| format!("{k}={}", v.join(" ")))
121        .collect();
122    pairs.sort();
123    pairs.join("\n")
124}
125
126fn provider_display(agent: &AgentConfig) -> String {
127    match &agent.provider {
128        None | Some(ProviderConfig::Inferred) => String::new(),
129        Some(ProviderConfig::Explicit(s)) => s.clone(),
130        Some(ProviderConfig::None) => "null".to_string(),
131    }
132}
133
134// -- form parsers --------------------------------------------------------------
135
136fn parse_vec(s: &str) -> Vec<String> {
137    s.lines()
138        .map(str::trim)
139        .filter(|l| !l.is_empty())
140        .map(String::from)
141        .collect()
142}
143
144fn parse_map(s: &str) -> HashMap<String, String> {
145    s.lines()
146        .map(str::trim)
147        .filter(|l| !l.is_empty())
148        .filter_map(|l| {
149            let (k, v) = l.split_once('=')?;
150            Some((k.trim().to_string(), v.trim().to_string()))
151        })
152        .collect()
153}
154
155fn parse_arg_maps(s: &str) -> HashMap<String, Vec<String>> {
156    s.lines()
157        .map(str::trim)
158        .filter(|l| !l.is_empty())
159        .filter_map(|l| {
160            let (k, rest) = l.split_once('=')?;
161            let vals: Vec<String> = rest.split_whitespace().map(String::from).collect();
162            Some((k.trim().to_string(), vals))
163        })
164        .collect()
165}
166
167fn non_empty_map<K, V>(m: HashMap<K, V>) -> Option<HashMap<K, V>> {
168    if m.is_empty() { None } else { Some(m) }
169}
170
171fn parse_provider(s: &str) -> Option<ProviderConfig> {
172    match s.trim() {
173        "" => None,
174        "null" => Some(ProviderConfig::None),
175        other => Some(ProviderConfig::Explicit(other.to_string())),
176    }
177}
178
179// -- HTML rendering ------------------------------------------------------------
180
181struct AgentDisplay {
182    command: String,
183    provider: String,
184    args: String,
185    models_str: String,
186    env_str: String,
187    pre_cmd: String,
188    arg_maps_str: String,
189}
190
191impl AgentDisplay {
192    fn new(agent: &AgentConfig) -> Self {
193        Self {
194            command: escape_html(&agent.command),
195            provider: escape_html(&provider_display(agent)),
196            args: escape_html(&fmt_vec(&agent.args)),
197            models_str: agent
198                .models
199                .as_ref()
200                .map_or_else(String::new, |m| escape_html(&fmt_map(m))),
201            env_str: agent
202                .env
203                .as_ref()
204                .map_or_else(String::new, |e| escape_html(&fmt_map(e))),
205            pre_cmd: escape_html(&fmt_vec(&agent.pre_command)),
206            arg_maps_str: escape_html(&fmt_arg_maps(&agent.arg_maps)),
207        }
208    }
209}
210
211fn render_agent_row(
212    idx: usize,
213    agent: &AgentConfig,
214    settings: &Settings,
215    model_keys: &[String],
216    now: &chrono::DateTime<Local>,
217) -> String {
218    let AgentDisplay {
219        command,
220        provider,
221        args,
222        models_str,
223        env_str,
224        pre_cmd,
225        arg_maps_str,
226    } = AgentDisplay::new(agent);
227
228    let priority_cells: String = model_keys
229        .iter()
230        .map(|mk| match priority_value(settings, agent, mk, now) {
231            None => r#"<td class="unavail">-</td>"#.to_string(),
232            Some(p) => format!(r#"<td class="prio">{p}</td>"#),
233        })
234        .collect();
235
236    format!(
237        r##"<tr id="agent-row-{idx}">
238  <td><span class="cmd-chip">{command}</span></td>
239  <td><span class="prov-text">{provider}</span></td>
240  <td><pre>{args}</pre></td>
241  <td><pre>{models_str}</pre></td>
242  <td><pre>{env_str}</pre></td>
243  <td><pre>{pre_cmd}</pre></td>
244  <td><pre>{arg_maps_str}</pre></td>
245  {priority_cells}
246  <td class="actions"><div class="actions-wrap">
247    <button class="btn-edit" hx-get="/agents/{idx}/edit" hx-target="#agent-row-{idx}" hx-swap="outerHTML">Edit</button>
248    <button class="btn-del" hx-delete="/agents/{idx}" hx-target="#agents-body" hx-swap="innerHTML">Del</button>
249  </div></td>
250</tr>"##
251    )
252}
253
254fn render_edit_row(
255    idx: usize,
256    agent: &AgentConfig,
257    settings: &Settings,
258    model_keys: &[String],
259    now: &chrono::DateTime<Local>,
260) -> String {
261    let AgentDisplay {
262        command,
263        provider,
264        args,
265        models_str,
266        env_str,
267        pre_cmd,
268        arg_maps_str,
269    } = AgentDisplay::new(agent);
270
271    let priority_inputs: String = model_keys
272        .iter()
273        .map(|mk| match priority_value(settings, agent, mk, now) {
274            None => r#"<td class="unavail">-</td>"#.to_string(),
275            Some(p) => {
276                let p_str = if p == 0 { String::new() } else { p.to_string() };
277                let safe_mk = escape_html(mk);
278                format!("<td><input name=\"p_{safe_mk}\" value=\"{p_str}\" placeholder=\"0\"></td>")
279            }
280        })
281        .collect();
282
283    format!(
284        r##"<tr id="agent-row-{idx}" class="editing">
285  <td><input name="command" value="{command}" placeholder="command"></td>
286  <td><input name="provider" value="{provider}" placeholder="(inferred)"></td>
287  <td><textarea name="args" rows="3">{args}</textarea></td>
288  <td><textarea name="models" rows="3">{models_str}</textarea></td>
289  <td><textarea name="env" rows="3">{env_str}</textarea></td>
290  <td><textarea name="pre_command" rows="3">{pre_cmd}</textarea></td>
291  <td><textarea name="arg_maps" rows="3">{arg_maps_str}</textarea></td>
292  {priority_inputs}
293  <td class="actions"><div class="actions-wrap">
294    <button class="btn-save" hx-put="/agents/{idx}" hx-include="closest tr" hx-target="#agents-body" hx-swap="innerHTML">Save</button>
295    <button class="btn-cancel" hx-get="/agents/{idx}" hx-target="#agent-row-{idx}" hx-swap="outerHTML">Cancel</button>
296  </div></td>
297</tr>"##
298    )
299}
300
301fn render_tbody(
302    settings: &Settings,
303    model_keys: &[String],
304    now: &chrono::DateTime<Local>,
305) -> String {
306    settings
307        .agents
308        .iter()
309        .enumerate()
310        .map(|(idx, agent)| render_agent_row(idx, agent, settings, model_keys, now))
311        .collect::<Vec<_>>()
312        .join("\n")
313}
314
315#[expect(
316    clippy::format_collect,
317    reason = "collecting formatted strings is intentional here"
318)]
319fn render_thead_model_cols(model_keys: &[String], sort_by: Option<&str>) -> String {
320    model_keys
321        .iter()
322        .map(|mk| {
323            let is_sorted = sort_by == Some(mk.as_str());
324            let class = if is_sorted {
325                r#" class="th-sorted""#
326            } else {
327                ""
328            };
329            let marker = if is_sorted { " v" } else { "" };
330            let encoded_mk = percent_encode_query(mk);
331            let escaped_mk = escape_html(mk);
332            format!("<th{class}><a href=\"/?sort={encoded_mk}\">{escaped_mk}{marker}</a></th>")
333        })
334        .collect()
335}
336
337#[expect(
338    clippy::too_many_lines,
339    reason = "single-function HTML template, splitting would harm readability"
340)]
341fn render_full_page(settings: &Settings, model_keys: &[String], sort_by: Option<&str>) -> String {
342    let now = Local::now();
343    let mut indexed: Vec<(usize, &AgentConfig)> = settings.agents.iter().enumerate().collect();
344    if let Some(sk) = sort_by {
345        indexed.sort_by_key(|(_, a)| {
346            std::cmp::Reverse(priority_value(settings, a, sk, &now).unwrap_or(i32::MIN))
347        });
348    }
349
350    let thead_model_cols = render_thead_model_cols(model_keys, sort_by);
351    let tbody: String = indexed
352        .iter()
353        .map(|(idx, agent)| render_agent_row(*idx, agent, settings, model_keys, &now))
354        .collect::<Vec<_>>()
355        .join("\n");
356
357    format!(
358        r##"<!DOCTYPE html>
359<html lang="en">
360<head>
361  <meta charset="utf-8">
362  <meta name="viewport" content="width=device-width, initial-scale=1">
363  <title>seher config</title>
364  <link rel="preconnect" href="https://fonts.googleapis.com">
365  <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600&family=Fira+Code:wght@400;500&display=swap" rel="stylesheet">
366  <script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
367  <style>
368    :root {{
369      --bg:      #060b14;
370      --s0:      #091220;
371      --s1:      #0d1929;
372      --s2:      #121f32;
373      --bd:      #1a2d42;
374      --bd2:     #253d58;
375      --t0:      #7a9ab8;
376      --t1:      #c0d4e8;
377      --t2:      #e0eeff;
378      --teal:    #00c9a7;
379      --teal-d:  rgba(0,201,167,.12);
380      --amber:   #ffc857;
381      --amber-d: rgba(255,200,87,.1);
382      --red:     #ff5a5f;
383      --red-d:   rgba(255,90,95,.1);
384      --green:   #05d69e;
385      --green-d: rgba(5,214,158,.1);
386    }}
387    *, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
388    html {{ scroll-behavior: smooth; }}
389    body {{
390      font-family: 'Outfit', system-ui, sans-serif;
391      background: var(--bg);
392      color: var(--t1);
393      min-height: 100vh;
394      display: flex;
395      flex-direction: column;
396      overflow-x: auto;
397    }}
398
399    /* -- Header -------------------------------------------------- */
400    .header {{
401      position: sticky; top: 0; z-index: 50;
402      background: rgba(6,11,20,.9);
403      backdrop-filter: blur(14px);
404      border-bottom: 1px solid var(--bd);
405      display: flex; align-items: center;
406      padding: 0 1.5rem; height: 52px; gap: 1rem;
407    }}
408    .logo {{
409      font-family: 'Fira Code', monospace;
410      font-weight: 500; font-size: .95rem;
411      color: var(--teal); letter-spacing: .05em;
412      display: flex; align-items: center; gap: .55rem;
413    }}
414    .logo-dot {{
415      width: 7px; height: 7px; border-radius: 50%;
416      background: var(--teal);
417      box-shadow: 0 0 6px var(--teal);
418      animation: blink 2.4s ease-in-out infinite;
419    }}
420    @keyframes blink {{
421      0%,100% {{ opacity:1; box-shadow: 0 0 6px var(--teal); }}
422      50% {{ opacity:.45; box-shadow: 0 0 2px var(--teal); }}
423    }}
424    .logo-sep {{ color: var(--bd2); margin: 0 .1rem; }}
425    .header-label {{
426      font-size: .68rem; font-weight: 400;
427      text-transform: uppercase; letter-spacing: .13em;
428      color: var(--t0);
429    }}
430    .header-right {{
431      margin-left: auto; display: flex;
432      align-items: center; gap: .75rem;
433    }}
434
435    /* -- Buttons ------------------------------------------------- */
436    button {{
437      cursor: pointer;
438      font-family: 'Outfit', sans-serif; font-weight: 500;
439      border-radius: 5px; border: 1px solid transparent;
440      transition: all .15s ease; line-height: 1; white-space: nowrap;
441    }}
442    .btn-primary {{
443      padding: .38rem 1.05rem; font-size: .8rem;
444      background: var(--amber); color: #1a0d00; border-color: var(--amber);
445    }}
446    .btn-primary:hover {{
447      background: #ffd47a;
448      box-shadow: 0 0 18px rgba(255,200,87,.4);
449    }}
450    .btn-edit {{
451      padding: .22rem .62rem; font-size: .7rem;
452      color: var(--teal); background: var(--teal-d);
453      border-color: rgba(0,201,167,.28);
454    }}
455    .btn-edit:hover {{
456      background: rgba(0,201,167,.2); border-color: var(--teal);
457      box-shadow: 0 0 8px rgba(0,201,167,.2);
458    }}
459    .btn-del {{
460      padding: .22rem .62rem; font-size: .7rem;
461      color: var(--red); background: var(--red-d);
462      border-color: rgba(255,90,95,.28);
463    }}
464    .btn-del:hover {{
465      background: rgba(255,90,95,.2); border-color: var(--red);
466    }}
467    .btn-save {{
468      padding: .22rem .62rem; font-size: .7rem;
469      color: var(--green); background: var(--green-d);
470      border-color: rgba(5,214,158,.28);
471    }}
472    .btn-save:hover {{
473      background: rgba(5,214,158,.2); border-color: var(--green);
474    }}
475    .btn-cancel {{
476      padding: .22rem .62rem; font-size: .7rem;
477      color: var(--t0); background: transparent; border-color: var(--bd);
478    }}
479    .btn-cancel:hover {{ color: var(--t1); border-color: var(--bd2); background: var(--s1); }}
480    .btn-add {{
481      padding: .38rem 1.1rem; font-size: .78rem;
482      color: var(--amber); background: transparent;
483      border: 1px dashed rgba(255,200,87,.38);
484    }}
485    .btn-add:hover {{ background: var(--amber-d); border-color: var(--amber); }}
486
487    /* -- Status badge -------------------------------------------- */
488    #status {{
489      display: inline-flex; align-items: center; gap: .35rem;
490      padding: .28rem .72rem;
491      background: var(--green-d); color: var(--green);
492      border: 1px solid rgba(5,214,158,.28);
493      border-radius: 4px; font-size: .73rem; font-weight: 500;
494      opacity: 0; pointer-events: none;
495      transition: opacity .2s ease;
496    }}
497    #status.show {{ opacity: 1; }}
498
499    /* -- Layout -------------------------------------------------- */
500    .main {{ padding: 1.25rem 1.5rem; flex: 1; min-width: 0; }}
501    .table-wrap {{
502      border: 1px solid var(--bd); border-radius: 8px;
503      overflow: auto; background: var(--s0);
504    }}
505
506    /* -- Table --------------------------------------------------- */
507    table {{ width: 100%; border-collapse: collapse; font-size: .77rem; }}
508    thead {{
509      background: var(--s2);
510      position: sticky; top: 0; z-index: 10;
511      border-bottom: 1px solid var(--bd2);
512    }}
513    th {{
514      padding: .52rem .82rem;
515      font-size: .63rem; font-weight: 600;
516      text-transform: uppercase; letter-spacing: .11em;
517      color: var(--t0); white-space: nowrap; text-align: left;
518      border-right: 1px solid var(--bd);
519    }}
520    th:last-child {{ border-right: none; }}
521    th a {{
522      color: inherit; text-decoration: none;
523      display: inline-flex; align-items: center; gap: .25rem;
524    }}
525    th a:hover {{ color: var(--teal); }}
526    .th-sorted {{ color: var(--teal) !important; }}
527    tbody tr {{ border-bottom: 1px solid var(--bd); transition: background .1s; }}
528    tbody tr:last-child {{ border-bottom: none; }}
529    tbody tr:hover {{ background: rgba(255,255,255,.016); }}
530    tbody tr.editing {{
531      background: rgba(255,200,87,.04);
532      box-shadow: inset 3px 0 0 var(--amber);
533    }}
534    td {{
535      padding: .48rem .82rem; vertical-align: top;
536      border-right: 1px solid var(--bd); color: var(--t1);
537    }}
538    td:last-child {{ border-right: none; }}
539    td.unavail {{ color: var(--bd2); text-align: center; }}
540    td.prio {{
541      text-align: center;
542      font-family: 'Fira Code', monospace; font-size: .7rem;
543      color: var(--teal); font-weight: 500;
544    }}
545    td.actions {{ white-space: nowrap; }}
546    .actions-wrap {{ display: flex; gap: .35rem; align-items: center; }}
547
548    /* -- Content cells ------------------------------------------- */
549    pre {{
550      margin: 0; white-space: pre-wrap; word-break: break-all;
551      font-family: 'Fira Code', monospace; font-size: .7rem;
552      color: var(--t1); line-height: 1.55;
553    }}
554    .cmd-chip {{
555      font-family: 'Fira Code', monospace; font-size: .7rem;
556      background: var(--teal-d); color: var(--teal);
557      border: 1px solid rgba(0,201,167,.22);
558      border-radius: 4px; padding: .14rem .52rem;
559      display: inline-block; white-space: nowrap;
560    }}
561    .prov-text {{
562      font-family: 'Fira Code', monospace; font-size: .7rem; color: var(--t0);
563    }}
564
565    /* -- Inputs -------------------------------------------------- */
566    input, textarea {{
567      background: var(--bg); border: 1px solid var(--bd);
568      border-radius: 4px; color: var(--t1);
569      font-family: 'Fira Code', monospace; font-size: .7rem;
570      padding: .3rem .5rem; width: 100%;
571      transition: border-color .15s, box-shadow .15s; resize: vertical;
572    }}
573    input:focus, textarea:focus {{
574      outline: none; border-color: var(--amber);
575      box-shadow: 0 0 0 2px rgba(255,200,87,.15);
576    }}
577    input[name="command"] {{ min-width: 96px; }}
578    input[name="provider"] {{ min-width: 78px; }}
579    input[name^="p_"] {{ width: 56px; text-align: center; }}
580    textarea {{ min-width: 110px; }}
581
582    /* -- Footer -------------------------------------------------- */
583    .footer {{ padding: .9rem 1.5rem; border-top: 1px solid var(--bd); }}
584
585    /* -- Scrollbar ----------------------------------------------- */
586    ::-webkit-scrollbar {{ width: 6px; height: 6px; }}
587    ::-webkit-scrollbar-track {{ background: var(--bg); }}
588    ::-webkit-scrollbar-thumb {{ background: var(--bd2); border-radius: 3px; }}
589    ::-webkit-scrollbar-thumb:hover {{ background: var(--t0); }}
590  </style>
591</head>
592<body>
593  <header class="header">
594    <div class="logo">
595      <span class="logo-dot"></span>
596      seher
597    </div>
598    <span class="logo-sep">/</span>
599    <span class="header-label">Config Editor</span>
600    <div class="header-right">
601      <span id="status">Saved &#10003;</span>
602      <button class="btn-primary"
603              hx-post="/save"
604              hx-target="#status"
605              hx-swap="innerHTML"
606              hx-on::after-request="const s=document.getElementById('status');s.classList.add('show');setTimeout(()=>s.classList.remove('show'),2600)">
607        Save to Disk
608      </button>
609    </div>
610  </header>
611  <main class="main">
612    <div class="table-wrap">
613      <table>
614        <thead>
615          <tr>
616            <th>command</th>
617            <th>provider</th>
618            <th>args</th>
619            <th>models</th>
620            <th>env</th>
621            <th>pre_command</th>
622            <th>arg_maps</th>
623            {thead_model_cols}
624            <th>actions</th>
625          </tr>
626        </thead>
627        <tbody id="agents-body">
628          {tbody}
629        </tbody>
630      </table>
631    </div>
632  </main>
633  <div class="footer">
634    <button class="btn-add" hx-post="/agents" hx-target="#agents-body" hx-swap="innerHTML">
635      + Add Agent
636    </button>
637  </div>
638</body>
639</html>"##
640    )
641}
642
643// -- handlers ------------------------------------------------------------------
644
645async fn index_handler(
646    State(state): State<SharedState>,
647    Query(params): Query<HashMap<String, String>>,
648) -> HandlerResult {
649    let settings = lock_settings(&state)?;
650    let sort_by = params.get("sort").map(String::as_str);
651    let model_keys = collect_model_keys(&settings);
652    Ok(Html(render_full_page(&settings, &model_keys, sort_by)))
653}
654
655async fn edit_agent_handler(
656    State(state): State<SharedState>,
657    Path(idx): Path<usize>,
658) -> HandlerResult {
659    let settings = lock_settings(&state)?;
660    let agent = settings
661        .agents
662        .get(idx)
663        .ok_or_else(|| (StatusCode::NOT_FOUND, "Agent not found".to_string()))?;
664    let model_keys = collect_model_keys(&settings);
665    let now = Local::now();
666    Ok(Html(render_edit_row(
667        idx,
668        agent,
669        &settings,
670        &model_keys,
671        &now,
672    )))
673}
674
675async fn view_agent_handler(
676    State(state): State<SharedState>,
677    Path(idx): Path<usize>,
678) -> HandlerResult {
679    let settings = lock_settings(&state)?;
680    let agent = settings
681        .agents
682        .get(idx)
683        .ok_or_else(|| (StatusCode::NOT_FOUND, "Agent not found".to_string()))?;
684    let model_keys = collect_model_keys(&settings);
685    let now = Local::now();
686    Ok(Html(render_agent_row(
687        idx,
688        agent,
689        &settings,
690        &model_keys,
691        &now,
692    )))
693}
694
695async fn update_agent_handler(
696    State(state): State<SharedState>,
697    Path(idx): Path<usize>,
698    Form(form): Form<HashMap<String, String>>,
699) -> HandlerResult {
700    let mut settings = lock_settings(&state)?;
701
702    if idx >= settings.agents.len() {
703        return Err((StatusCode::NOT_FOUND, "Agent not found".to_string()));
704    }
705
706    let command = form
707        .get("command")
708        .map_or_else(String::new, |s| s.trim().to_string());
709    let provider = parse_provider(form.get("provider").map_or("", String::as_str));
710    let args = parse_vec(form.get("args").map_or("", String::as_str));
711    let models = non_empty_map(parse_map(form.get("models").map_or("", String::as_str)));
712    let env = non_empty_map(parse_map(form.get("env").map_or("", String::as_str)));
713    let pre_command = parse_vec(form.get("pre_command").map_or("", String::as_str));
714    let arg_maps = parse_arg_maps(form.get("arg_maps").map_or("", String::as_str));
715
716    {
717        let agent = &mut settings.agents[idx];
718        agent.command = command;
719        agent.provider = provider;
720        agent.args = args;
721        agent.models = models;
722        agent.env = env;
723        agent.pre_command = pre_command;
724        agent.arg_maps = arg_maps;
725    }
726
727    // Process priority fields: "p_{model_key}" -> update PriorityRules
728    let agent_command = settings.agents[idx].command.clone();
729    let agent_provider = settings.agents[idx].provider.clone();
730
731    for (key, val) in &form {
732        let Some(model_suffix) = key.strip_prefix("p_") else {
733            continue;
734        };
735        let model_key: Option<String> = if model_suffix == "(none)" {
736            None
737        } else {
738            Some(model_suffix.to_string())
739        };
740
741        let trimmed = val.trim();
742        if trimmed.is_empty() || trimmed == "0" {
743            settings.remove_priority(
744                &agent_command,
745                agent_provider.as_ref(),
746                model_key.as_deref(),
747            );
748        } else if let Ok(p) = trimmed.parse::<i32>() {
749            settings.upsert_priority(&agent_command, agent_provider.clone(), model_key, p);
750        }
751    }
752
753    let model_keys = collect_model_keys(&settings);
754    let now = Local::now();
755    Ok(Html(render_tbody(&settings, &model_keys, &now)))
756}
757
758async fn add_agent_handler(State(state): State<SharedState>) -> HandlerResult {
759    let mut settings = lock_settings(&state)?;
760    settings.agents.push(AgentConfig {
761        command: "new-agent".to_string(),
762        args: vec![],
763        models: None,
764        arg_maps: HashMap::new(),
765        env: None,
766        provider: None,
767        openrouter_management_key: None,
768        glm_api_key: None,
769        pre_command: vec![],
770    });
771    let model_keys = collect_model_keys(&settings);
772    let now = Local::now();
773    Ok(Html(render_tbody(&settings, &model_keys, &now)))
774}
775
776async fn delete_agent_handler(
777    State(state): State<SharedState>,
778    Path(idx): Path<usize>,
779) -> HandlerResult {
780    let mut settings = lock_settings(&state)?;
781    if idx >= settings.agents.len() {
782        return Err((StatusCode::NOT_FOUND, "Agent not found".to_string()));
783    }
784    settings.agents.remove(idx);
785    let model_keys = collect_model_keys(&settings);
786    let now = Local::now();
787    Ok(Html(render_tbody(&settings, &model_keys, &now)))
788}
789
790async fn save_handler(State(state): State<SharedState>) -> HandlerResult {
791    let settings = lock_settings(&state)?;
792    settings
793        .save(state.config_path.as_deref())
794        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
795    Ok(Html("Saved &#10003;".to_string()))
796}
797
798// -- entry point ---------------------------------------------------------------
799
800/// Start the config editor web server, open the browser, and block until Ctrl+C.
801///
802/// # Errors
803///
804/// Returns an error if the TCP listener cannot be bound or the server fails.
805pub async fn serve(
806    settings: Settings,
807    config_path: Option<PathBuf>,
808) -> Result<(), Box<dyn std::error::Error>> {
809    let state = Arc::new(AppState {
810        settings: Mutex::new(settings),
811        config_path,
812    });
813
814    let app = Router::new()
815        .route("/", get(index_handler))
816        .route("/agents/{idx}/edit", get(edit_agent_handler))
817        .route(
818            "/agents/{idx}",
819            get(view_agent_handler)
820                .put(update_agent_handler)
821                .delete(delete_agent_handler),
822        )
823        .route("/agents", post(add_agent_handler))
824        .route("/save", post(save_handler))
825        .with_state(state);
826
827    let listener = TcpListener::bind("127.0.0.1:0").await?;
828    let port = listener.local_addr()?.port();
829    eprintln!("Config editor: http://127.0.0.1:{port}");
830    let _ = open::that(format!("http://127.0.0.1:{port}"));
831    axum::serve(listener, app).await?;
832    Ok(())
833}