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