1#![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
23struct 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
42fn 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
64fn 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))), }
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('&', "&")
95 .replace('<', "<")
96 .replace('>', ">")
97 .replace('"', """)
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
127fn 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
172struct 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 ✓</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
629async 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 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 ✓".to_string()))
764}
765
766pub 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}