返回 CodeWhale
router_setup.rs
根目录 / crates / tui / src / tui / views / router_setup.rs
1 //! Router setup (#6525): the one view behind `/router` and `/model router`.
2 //!
3 //! It writes the existing `[auto.router]` table and nothing else — there is
4 //! no second routing system. Presets derive from route capabilities (the
5 //! decision API a key can reach, the active provider's runnable fast tier),
6 //! never from model-name tables:
7 //!
8 //! * **Jev** — TypeSafe's decision model through OpenRouter or TypeSafe
9 //! direct, whichever key exists (OpenRouter first).
10 //! * **Fast** — the active provider's runnable fast sibling, thinking off.
11 //! * **Off** — removes `[auto.router]`; Auto takes its local fallback: the
12 //! default model, or the fast tier when `[auto] cost_saving` is on (Off
13 //! leaves that separate opt-in alone and says so).
14 //! * **Custom** — shows the TOML to edit by hand.
15 //!
16 //! Choosing Jev or Fast makes exactly one test call through the per-turn
17 //! routing path and shows the result before anything is saved; saving is a
18 //! separate, explicit step. No preset is ever elected automatically. The test
19 //! call's usage enters session cost like any routing call, and a save is
20 //! published to the runtime threads as well as the UI's config.
21 //!
22 //! Known limits: "Free OpenRouter model" and "Local model" presets need live
23 //! catalog and reachability probes and are not offered yet; the `/model`
24 //! picker "Router" row is a follow-up.
25
26 use std::cell::RefCell;
27 use std::path::{Path, PathBuf};
28
29 use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
30 use ratatui::{
31 buffer::Buffer,
32 layout::Rect,
33 style::{Modifier, Style},
34 text::{Line, Span},
35 widgets::{Block, Borders, Padding, Paragraph, Widget, Wrap},
36 };
37
38 use crate::client::system_one::DecisionRouterRoute;
39 use crate::config::{AutoConfig, AutoRouterConfig, Config, ProviderKind};
40 use crate::model_inventory::ModelInventory;
41 use crate::model_routing::{AutoRouteSelection, provider_router_candidates};
42 use crate::tui::app::{App, StatusToastLevel};
43 use crate::tui::history::HistoryCell;
44 use crate::tui::menu_style;
45 use crate::tui::views::{
46 ActionHint, CommandPaletteAction, ModalKind, ModalView, ViewAction, ViewEvent,
47 centered_modal_area, render_modal_footer, render_modal_surface,
48 };
49 use codewhale_localization::{Locale, MessageId, tr};
50 use codewhale_palette as palette;
51
52 /// Pinned OpenRouter id for Jev; `~typesafe/jev-latest` is also accepted.
53 pub(crate) const JEV_OPENROUTER_MODEL: &str = "typesafe/jev-1.13";
54 /// TypeSafe direct model id.
55 pub(crate) const JEV_TYPESAFE_MODEL: &str = "jev-latest";
56 /// Router timeout the Jev preset writes; Jev's p50 is ~0.1–0.2 s.
57 const JEV_TIMEOUT_SECS: u64 = 2;
58
59 /// A Router setup preset.
60 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
61 pub(crate) enum RouterPreset {
62 /// Jev; `None` picks whichever route has a key (OpenRouter first).
63 Jev(Option<DecisionRouterRoute>),
64 Fast,
65 Off,
66 Custom,
67 }
68
69 impl RouterPreset {
70 /// Parse `jev [openrouter|typesafe]`, `fast`, `off` or `custom`.
71 #[must_use]
72 pub(crate) fn parse(args: &str) -> Option<Self> {
73 let mut words = args.split_whitespace();
74 let preset = match words.next()?.to_ascii_lowercase().as_str() {
75 "jev" => Self::Jev(match words.next() {
76 None => None,
77 Some(route) => Some(DecisionRouterRoute::parse(route)?),
78 }),
79 "fast" => Self::Fast,
80 "off" | "none" => Self::Off,
81 "custom" => Self::Custom,
82 _ => return None,
83 };
84 words.next().is_none().then_some(preset)
85 }
86
87 /// The `/router` arguments that select this preset.
88 #[must_use]
89 pub(crate) fn command_args(self) -> String {
90 match self {
91 Self::Jev(None) => "jev".to_string(),
92 Self::Jev(Some(route)) => format!("jev {}", route.as_str()),
93 Self::Fast => "fast".to_string(),
94 Self::Off => "off".to_string(),
95 Self::Custom => "custom".to_string(),
96 }
97 }
98 }
99
100 /// What `/router` asked the app to do.
101 #[derive(Debug, Clone, PartialEq, Eq)]
102 pub(crate) enum RouterRequest {
103 /// Open the Router setup view.
104 Open,
105 /// Make one test call with this preset and show the result.
106 Test(RouterPreset),
107 /// Persist this preset to `config.toml` and the live config.
108 Save(RouterPreset),
109 }
110
111 /// Parse `/router` (or `/model router`) arguments.
112 pub(crate) fn parse_router_args(args: Option<&str>) -> Result<RouterRequest, String> {
113 let args = args.map(str::trim).unwrap_or_default();
114 if args.is_empty() {
115 return Ok(RouterRequest::Open);
116 }
117 let (save, rest) = match args.split_once(char::is_whitespace) {
118 Some((head, rest)) if head.eq_ignore_ascii_case("save") => (true, rest.trim()),
119 _ => (false, args),
120 };
121 let preset = RouterPreset::parse(rest).ok_or_else(|| {
122 "Usage: /router [jev [openrouter|typesafe]|fast|off|custom] · /router save <preset>"
123 .to_string()
124 })?;
125 Ok(if save {
126 RouterRequest::Save(preset)
127 } else {
128 RouterRequest::Test(preset)
129 })
130 }
131
132 /// The `[auto.router]` table a preset writes for this config, `Ok(None)` for
133 /// Off, or a non-secret reason the preset is unavailable.
134 pub(crate) fn preset_router_config(
135 config: &Config,
136 preset: RouterPreset,
137 locale: Locale,
138 ) -> Result<Option<AutoRouterConfig>, String> {
139 match preset {
140 RouterPreset::Off => Ok(None),
141 RouterPreset::Custom => Err(tr(locale, MessageId::RouterCustomByHand).into_owned()),
142 RouterPreset::Jev(route) => {
143 let route = match route {
144 Some(route) if route.has_key(config) => route,
145 Some(route) => {
146 return Err(no_key_reason(locale, route));
147 }
148 None => [
149 DecisionRouterRoute::Openrouter,
150 DecisionRouterRoute::Typesafe,
151 ]
152 .into_iter()
153 .find(|route| route.has_key(config))
154 .ok_or_else(|| tr(locale, MessageId::RouterNeedKey).into_owned())?,
155 };
156 let model = match route {
157 DecisionRouterRoute::Openrouter => JEV_OPENROUTER_MODEL,
158 DecisionRouterRoute::Typesafe => JEV_TYPESAFE_MODEL,
159 };
160 Ok(Some(AutoRouterConfig {
161 kind: Some("decision".to_string()),
162 provider: Some(route.as_str().to_string()),
163 model: Some(model.to_string()),
164 timeout_secs: Some(JEV_TIMEOUT_SECS),
165 min_confidence: Some(crate::config::DEFAULT_AUTO_ROUTER_MIN_CONFIDENCE),
166 ..Default::default()
167 }))
168 }
169 RouterPreset::Fast => {
170 let (provider, fast) = runnable_fast_tier(config)
171 .ok_or_else(|| tr(locale, MessageId::RouterNoFastTier).into_owned())?;
172 Ok(Some(AutoRouterConfig {
173 kind: Some("chat".to_string()),
174 provider: Some(provider.as_str().to_string()),
175 model: Some(fast),
176 thinking: Some("off".to_string()),
177 ..Default::default()
178 }))
179 }
180 }
181 }
182
183 /// What Off does for this config: `[auto] cost_saving` is a separate opt-in
184 /// that Off leaves in place, so its fallback is the fast tier, not the default.
185 fn off_hint(config: &Config, locale: Locale) -> String {
186 let id = if config.auto_cost_saving() {
187 MessageId::RouterPresetOffCostSavingHint
188 } else {
189 MessageId::RouterPresetOffHint
190 };
191 tr(locale, id).into_owned()
192 }
193
194 fn no_key_reason(locale: Locale, route: DecisionRouterRoute) -> String {
195 tr(locale, MessageId::RouterNoKey).replace("{route}", route.display_name())
196 }
197
198 /// The active provider's runnable fast sibling, from route capabilities.
199 fn runnable_fast_tier(config: &Config) -> Option<(ProviderKind, String)> {
200 let inventory = ModelInventory::from_config(config).ok()?;
201 let active = inventory.active_default()?;
202 let fast = provider_router_candidates(active.provider, &active.model).cheap?;
203 let candidate = inventory
204 .candidate(active.identity.key.as_str(), &fast)
205 .filter(|candidate| candidate.readiness.can_attempt())?;
206 Some((active.provider, candidate.model.clone()))
207 }
208
209 /// Write `router` as `[auto.router]` (or remove the table for `None`) through
210 /// the single config writer, replacing any previous router keys.
211 pub(crate) fn persist_auto_router(
212 config_path: Option<&Path>,
213 router: Option<&AutoRouterConfig>,
214 ) -> anyhow::Result<PathBuf> {
215 use crate::config_persistence::{
216 config_toml_path, mutate_config_document, set_document_value, unset_document_value,
217 };
218 let path = config_toml_path(config_path)?;
219 mutate_config_document(&path, |doc| {
220 unset_document_value(doc, &["auto", "router"])?;
221 let Some(router) = router else {
222 return Ok(());
223 };
224 let strings = [
225 ("kind", router.kind.as_deref()),
226 ("provider", router.provider.as_deref()),
227 ("model", router.model.as_deref()),
228 ("thinking", router.thinking.as_deref()),
229 ("base_url", router.base_url.as_deref()),
230 ];
231 for (key, value) in strings {
232 if let Some(value) = value {
233 set_document_value(doc, &["auto", "router", key], value)?;
234 }
235 }
236 if let Some(secs) = router.timeout_secs {
237 let secs = i64::try_from(secs).unwrap_or(i64::MAX);
238 set_document_value(doc, &["auto", "router", "timeout_secs"], secs)?;
239 }
240 if let Some(min_confidence) = router.min_confidence {
241 set_document_value(doc, &["auto", "router", "min_confidence"], min_confidence)?;
242 }
243 Ok(())
244 })?;
245 Ok(path)
246 }
247
248 /// Handle a `/router` request on the UI loop, where the live config lives.
249 pub(crate) async fn handle_router_request(
250 app: &mut App,
251 config: &mut Config,
252 task_manager: &crate::task_manager::TaskManager,
253 request: RouterRequest,
254 ) {
255 match request {
256 RouterRequest::Open => {
257 if app.view_stack.top_kind() != Some(ModalKind::RouterSetup) {
258 app.view_stack
259 .push(RouterSetupView::picker(config, app.ui_locale));
260 }
261 }
262 RouterRequest::Test(RouterPreset::Custom) => {
263 app.add_message(HistoryCell::System {
264 content: custom_router_help(config, app.ui_locale),
265 });
266 }
267 RouterRequest::Test(preset) => test_preset(app, config, preset).await,
268 RouterRequest::Save(preset) => save_preset(app, config, task_manager, preset).await,
269 }
270 }
271
272 async fn test_preset(app: &mut App, config: &Config, preset: RouterPreset) {
273 let locale = app.ui_locale;
274 let router = match preset_router_config(config, preset, locale) {
275 Ok(router) => router,
276 Err(reason) => {
277 app.push_status_toast(
278 tr(app.ui_locale, MessageId::RouterPresetUnavailable).replace("{reason}", &reason),
279 StatusToastLevel::Warning,
280 Some(6_000),
281 );
282 return;
283 }
284 };
285 let lines = match router.as_ref() {
286 None => vec![off_hint(config, locale)],
287 Some(router) => {
288 // The test call awaits a provider inline on the UI loop; refuse
289 // while Runtime Chat owns the run, as other inline calls do.
290 if app.remote_control.runtime_chat_blocks_local_dispatch() {
291 app.push_status_toast(
292 tr(app.ui_locale, MessageId::SettingLockedDuringTurn)
293 .replace("{setting}", "Codewhale Runtime"),
294 StatusToastLevel::Info,
295 Some(6_000),
296 );
297 return;
298 }
299 let mut candidate = config.clone();
300 candidate
301 .auto
302 .get_or_insert_with(AutoConfig::default)
303 .router = Some(router.clone());
304 let mut lines = vec![router_summary(router, locale)];
305 // Captured before the call, like any background provider request.
306 let cost_scope = crate::cost_status::scope_token();
307 match crate::model_routing::test_auto_router(&candidate).await {
308 Ok((selection, latency_ms)) => {
309 // The test call is real spend: settle it into session
310 // cost exactly as a turn's routing call is settled.
311 crate::cost_status::report_runtime_usage_batch(
312 cost_scope,
313 None,
314 &crate::cost_status::RuntimeUsageBatch {
315 decisions: Vec::new(),
316 records: selection.routed_usage.clone(),
317 drop_records: selection.routed_usage_drop_records.clone(),
318 dropped_records: selection.routed_usage_dropped_records,
319 },
320 );
321 lines.extend(describe_test_selection(&selection, latency_ms, locale));
322 }
323 Err(reason) => lines
324 .push(tr(locale, MessageId::RouterTestNotMade).replace("{reason}", &reason)),
325 }
326 lines
327 }
328 };
329 app.add_message(HistoryCell::System {
330 content: format!(
331 "{}\n{}",
332 tr(locale, MessageId::RouterTestHeader).replace("{args}", &preset.command_args()),
333 lines.join("\n")
334 ),
335 });
336 app.view_stack
337 .push(RouterSetupView::confirm(preset, lines, app.ui_locale));
338 }
339
340 async fn save_preset(
341 app: &mut App,
342 config: &mut Config,
343 task_manager: &crate::task_manager::TaskManager,
344 preset: RouterPreset,
345 ) {
346 let locale = app.ui_locale;
347 let router = match preset_router_config(config, preset, locale) {
348 Ok(router) => router,
349 Err(reason) => {
350 app.add_message(HistoryCell::System {
351 content: tr(locale, MessageId::RouterPresetUnavailable)
352 .replace("{reason}", &reason),
353 });
354 return;
355 }
356 };
357 // The config writer reads, locks and rewrites config.toml; keep that
358 // file IO off the UI runtime (#6149).
359 let config_path = app.config_path.clone();
360 let to_write = router.clone();
361 let persisted = tokio::task::spawn_blocking(move || {
362 persist_auto_router(config_path.as_deref(), to_write.as_ref())
363 })
364 .await
365 .map_err(anyhow::Error::from)
366 .and_then(|result| result);
367 match persisted {
368 Ok(path) => {
369 config.auto.get_or_insert_with(AutoConfig::default).router = router.clone();
370 let what = router.as_ref().map_or_else(
371 || tr(locale, MessageId::ConfigValueOff).into_owned(),
372 |router| router_summary(router, locale),
373 );
374 app.add_message(HistoryCell::System {
375 content: tr(locale, MessageId::RouterSaved)
376 .replace("{router}", &what)
377 .replace("{path}", &path.display().to_string()),
378 });
379 // Runtime-chat and queued runtime turns resolve Auto from the
380 // runtime manager's own config snapshot; publish the new router
381 // there too, or they keep routing with the old one.
382 let runtime_router = router.clone();
383 if let Err(error) = task_manager
384 .reload_runtime_config_with(|runtime| {
385 runtime.auto.get_or_insert_with(AutoConfig::default).router = runtime_router;
386 })
387 .await
388 {
389 app.add_message(HistoryCell::Error {
390 message: tr(locale, MessageId::RouterRuntimeNotReloaded)
391 .replace("{error}", &error.to_string()),
392 severity: crate::error_taxonomy::ErrorSeverity::Warning,
393 });
394 }
395 }
396 Err(error) => app.add_message(HistoryCell::Error {
397 message: tr(locale, MessageId::RouterNotSaved).replace("{error}", &error.to_string()),
398 severity: crate::error_taxonomy::ErrorSeverity::Error,
399 }),
400 }
401 }
402
403 fn router_summary(router: &AutoRouterConfig, locale: Locale) -> String {
404 let kind = router.kind.as_deref().unwrap_or("chat");
405 let provider = router.provider.as_deref().unwrap_or("?");
406 let model = router.model.as_deref().unwrap_or("?");
407 let id = if router.thinking.is_some() {
408 MessageId::RouterSummaryThinking
409 } else {
410 MessageId::RouterSummary
411 };
412 tr(locale, id)
413 .replace("{kind}", kind)
414 .replace("{provider}", provider)
415 .replace("{model}", model)
416 .replace("{thinking}", router.thinking.as_deref().unwrap_or_default())
417 }
418
419 /// The test result: which tier it picked, why, and what the call cost.
420 fn describe_test_selection(
421 selection: &AutoRouteSelection,
422 latency_ms: u64,
423 locale: Locale,
424 ) -> Vec<String> {
425 let percent = |bp: u16| format!("{}%", (u32::from(bp) + 50) / 100);
426 let mut lines = vec![
427 tr(locale, MessageId::RouterTestWouldRoute)
428 .replace("{request}", crate::model_routing::ROUTER_TEST_REQUEST)
429 .replace(
430 "{provider}",
431 selection
432 .provider
433 .compatibility()
434 .map_or(selection.provider.key.as_str(), |row| row.label),
435 )
436 .replace("{model}", &selection.model)
437 .replace("{latency}", &latency_ms.to_string()),
438 ];
439 let Some(receipt) = selection.receipt.as_ref() else {
440 return lines;
441 };
442 lines.push(
443 tr(locale, MessageId::RouterTestDecision)
444 .replace("{tier}", receipt.tier.label())
445 .replace("{reason}", &receipt.reason.label()),
446 );
447 if let Some(decision) = receipt.decision.as_ref() {
448 let probabilities = decision
449 .probabilities_bp
450 .iter()
451 .map(|(option, bp)| format!("{option} {}", percent(*bp)))
452 .collect::<Vec<_>>()
453 .join(" · ");
454 lines.push(
455 tr(locale, MessageId::RouterTestChoice)
456 .replace("{choice}", &decision.choice)
457 .replace("{probabilities}", &probabilities)
458 .replace("{confidence}", &percent(decision.confidence_bp))
459 .replace("{min}", &percent(decision.min_confidence_bp)),
460 );
461 lines.push(match decision.provider_reported_cost_usd.as_deref() {
462 Some(cost) => tr(locale, MessageId::RouterTestCostReported).replace("{cost}", cost),
463 None => tr(locale, MessageId::RouterTestCostUnreported).into_owned(),
464 });
465 } else if let Some(usage) = selection.routed_usage.first() {
466 lines.push(
467 tr(locale, MessageId::RouterTestUsage)
468 .replace("{input}", &usage.usage.usage.input_tokens.to_string())
469 .replace("{output}", &usage.usage.usage.output_tokens.to_string()),
470 );
471 }
472 if let Some(failure) = receipt.router_failure {
473 lines.push(tr(locale, MessageId::RouterTestFailing).replace("{reason}", &failure.label()));
474 }
475 lines
476 }
477
478 fn custom_router_help(config: &Config, locale: Locale) -> String {
479 let current = config
480 .auto
481 .as_ref()
482 .and_then(|auto| auto.router.as_ref())
483 .map_or_else(
484 || tr(locale, MessageId::ConfigValueOff).into_owned(),
485 |router| router_summary(router, locale),
486 );
487 format!(
488 "{}\n{}\n\n\
489 [auto.router]\n\
490 kind = \"chat\" # or \"decision\" for Jev\n\
491 provider = \"deepseek\" # decision: \"openrouter\" or \"typesafe\"\n\
492 model = \"deepseek-v4-flash\"\n\
493 thinking = \"off\" # chat routers only\n\
494 timeout_secs = 4\n\
495 # min_confidence = 0.5 # decision routers only\n\n\
496 {}",
497 tr(locale, MessageId::RouterCurrent).replace("{router}", &current),
498 tr(locale, MessageId::RouterCustomHelpIntro),
499 tr(locale, MessageId::RouterCustomHelpOutro),
500 )
501 }
502
503 struct PresetRow {
504 preset: RouterPreset,
505 label: String,
506 hint: String,
507 available: bool,
508 }
509
510 enum Mode {
511 Pick,
512 Confirm {
513 preset: RouterPreset,
514 lines: Vec<String>,
515 },
516 }
517
518 /// The Router setup view: a preset picker, then a test-result confirmation.
519 pub(crate) struct RouterSetupView {
520 mode: Mode,
521 rows: Vec<PresetRow>,
522 current: String,
523 cursor: usize,
524 locale: Locale,
525 /// `(row index, rect)` for the rows actually drawn this frame.
526 row_hitboxes: RefCell<Vec<(usize, Rect)>>,
527 }
528
529 impl RouterSetupView {
530 fn picker(config: &Config, locale: Locale) -> Self {
531 let unavailable = |reason: String| {
532 tr(locale, MessageId::RouterPresetUnavailable).replace("{reason}", &reason)
533 };
534 let mut rows = Vec::new();
535 for route in [
536 DecisionRouterRoute::Openrouter,
537 DecisionRouterRoute::Typesafe,
538 ] {
539 let preset = RouterPreset::Jev(Some(route));
540 let mut hint =
541 tr(locale, MessageId::RouterPresetJevHint).replace("{route}", route.display_name());
542 let available = route.has_key(config);
543 if !available {
544 hint = unavailable(no_key_reason(locale, route));
545 }
546 if route == DecisionRouterRoute::Typesafe {
547 hint = format!(
548 "{hint} · {}",
549 tr(locale, MessageId::RouterTypesafeSignupPaused)
550 );
551 }
552 rows.push(PresetRow {
553 preset,
554 label: format!("Jev · {}", route.display_name()),
555 hint,
556 available,
557 });
558 }
559 let fast = runnable_fast_tier(config);
560 rows.push(PresetRow {
561 preset: RouterPreset::Fast,
562 label: tr(locale, MessageId::RouterPresetFastLabel).into_owned(),
563 hint: match fast.as_ref() {
564 Some((provider, model)) => tr(locale, MessageId::RouterPresetFastHint)
565 .replace("{model}", model)
566 .replace("{provider}", provider.provider().display_name()),
567 None => unavailable(tr(locale, MessageId::RouterNoFastTier).into_owned()),
568 },
569 available: fast.is_some(),
570 });
571 rows.push(PresetRow {
572 preset: RouterPreset::Off,
573 label: tr(locale, MessageId::ConfigValueOff).into_owned(),
574 hint: off_hint(config, locale),
575 available: true,
576 });
577 rows.push(PresetRow {
578 preset: RouterPreset::Custom,
579 label: tr(locale, MessageId::RouterPresetCustomLabel).into_owned(),
580 hint: tr(locale, MessageId::RouterPresetCustomHint).into_owned(),
581 available: true,
582 });
583 let inventory = ModelInventory::from_config(config);
584 let current = match config.auto.as_ref().and_then(|auto| auto.router.as_ref()) {
585 None => tr(locale, MessageId::RouterCurrent)
586 .replace("{router}", &tr(locale, MessageId::ConfigValueOff)),
587 Some(router) => match inventory {
588 Err(reason) => tr(locale, MessageId::RouterCurrentFailing)
589 .replace("{router}", &router_summary(router, locale))
590 .replace("{reason}", &reason),
591 Ok(inventory) => match inventory.router_setup_issue {
592 Some(issue) => tr(locale, MessageId::RouterCurrentFailing)
593 .replace("{router}", &router_summary(router, locale))
594 .replace("{reason}", issue.label()),
595 None => tr(locale, MessageId::RouterCurrent)
596 .replace("{router}", &router_summary(router, locale)),
597 },
598 },
599 };
600 Self {
601 mode: Mode::Pick,
602 rows,
603 current,
604 cursor: 0,
605 locale,
606 row_hitboxes: RefCell::new(Vec::new()),
607 }
608 }
609
610 fn confirm(preset: RouterPreset, lines: Vec<String>, locale: Locale) -> Self {
611 Self {
612 mode: Mode::Confirm { preset, lines },
613 rows: Vec::new(),
614 current: String::new(),
615 cursor: 0,
616 locale,
617 row_hitboxes: RefCell::new(Vec::new()),
618 }
619 }
620
621 fn run(command: String) -> ViewAction {
622 ViewAction::EmitAndClose(ViewEvent::CommandPaletteSelected {
623 action: CommandPaletteAction::ExecuteCommand { command },
624 })
625 }
626
627 fn select(&self) -> ViewAction {
628 match &self.mode {
629 Mode::Confirm { preset, .. } => {
630 Self::run(format!("/router save {}", preset.command_args()))
631 }
632 Mode::Pick => match self.rows.get(self.cursor) {
633 Some(row) if row.available => {
634 Self::run(format!("/router {}", row.preset.command_args()))
635 }
636 _ => ViewAction::None,
637 },
638 }
639 }
640 }
641
642 impl ModalView for RouterSetupView {
643 fn kind(&self) -> ModalKind {
644 ModalKind::RouterSetup
645 }
646
647 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
648 self
649 }
650
651 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
652 if matches!(self.mode, Mode::Pick)
653 && let Some(motion) = crate::tui::list_nav::motion(&key)
654 && let Some(next) =
655 crate::tui::list_nav::apply(self.cursor, self.rows.len(), self.rows.len(), motion)
656 {
657 self.cursor = next;
658 return ViewAction::None;
659 }
660 match key.code {
661 KeyCode::Esc => ViewAction::Close,
662 KeyCode::Enter => self.select(),
663 _ => ViewAction::None,
664 }
665 }
666
667 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
668 if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
669 let clicked = self
670 .row_hitboxes
671 .borrow()
672 .iter()
673 .find(|(_, rect)| {
674 rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
675 })
676 .map(|(index, _)| *index);
677 if let Some(index) = clicked {
678 self.cursor = index;
679 return self.select();
680 }
681 }
682 ViewAction::None
683 }
684
685 fn render(&self, area: Rect, buf: &mut Buffer) {
686 let body_rows = match &self.mode {
687 Mode::Pick => self.rows.len() * 2 + 2,
688 Mode::Confirm { lines, .. } => lines.len() + 3,
689 };
690 let popup_height = u16::try_from(body_rows)
691 .unwrap_or(u16::MAX)
692 .saturating_add(6);
693 let popup_area = centered_modal_area(area, 96, popup_height, 44, 10);
694 render_modal_surface(area, popup_area, buf);
695
696 let title = format!(" {} ", tr(self.locale, MessageId::RouterSetupTitle));
697 let block = Block::default()
698 .title(Line::from(Span::styled(
699 title,
700 Style::default()
701 .fg(palette::WHALE_ACTION)
702 .add_modifier(Modifier::BOLD),
703 )))
704 .borders(Borders::ALL)
705 .border_style(Style::default().fg(palette::BORDER_COLOR))
706 .style(Style::default().bg(palette::WHALE_BG))
707 .padding(Padding::uniform(1));
708 let inner = block.inner(popup_area);
709 block.render(popup_area, buf);
710
711 let locale = self.locale;
712 let hints = match self.mode {
713 Mode::Pick => vec![
714 ActionHint::new("↑/↓", tr(locale, MessageId::PickerActionMove)),
715 ActionHint::new("Enter", tr(locale, MessageId::RouterActionTest)),
716 ActionHint::new("Esc", tr(locale, MessageId::SessionsActionClose)),
717 ],
718 Mode::Confirm { .. } => vec![
719 ActionHint::new("Enter", tr(locale, MessageId::RouterActionSave)),
720 ActionHint::new("Esc", tr(locale, MessageId::RouterActionDiscard)),
721 ],
722 };
723 let content = render_modal_footer(inner, buf, &hints);
724 self.row_hitboxes.borrow_mut().clear();
725
726 let muted = Style::default().fg(palette::TEXT_MUTED);
727 let mut lines = Vec::new();
728 match &self.mode {
729 Mode::Confirm { lines: result, .. } => {
730 for line in result {
731 lines.push(Line::from(Span::styled(
732 line.clone(),
733 Style::default().fg(palette::TEXT_PRIMARY),
734 )));
735 }
736 lines.push(Line::from(""));
737 lines.push(Line::from(Span::styled(
738 tr(self.locale, MessageId::RouterTestSaveHint).into_owned(),
739 muted,
740 )));
741 }
742 Mode::Pick => {
743 // Label + hint per row when everything fits; otherwise one
744 // line per row, windowed so the focused row is always drawn
745 // (compact terminals, e.g. 40x12).
746 let height = usize::from(content.height);
747 let width = usize::from(content.width);
748 let two_line = height >= 2 + self.rows.len() * 2;
749 let header = if two_line {
750 2
751 } else {
752 usize::from(height >= 2)
753 };
754 let row_height: u16 = if two_line { 2 } else { 1 };
755 let visible = (height.saturating_sub(header) / usize::from(row_height)).max(1);
756 let start = self
757 .cursor
758 .saturating_sub(visible - 1)
759 .min(self.rows.len().saturating_sub(visible));
760 if header > 0 {
761 lines.push(Line::from(Span::styled(
762 crate::tui::ui_text::semantic_truncate(&self.current, width),
763 muted,
764 )));
765 }
766 if header > 1 {
767 lines.push(Line::from(""));
768 }
769 for (idx, row) in self.rows.iter().enumerate().skip(start).take(visible) {
770 let is_cursor = idx == self.cursor;
771 let label_style = if is_cursor {
772 menu_style::selected_row_style()
773 } else if row.available {
774 Style::default().fg(palette::TEXT_PRIMARY)
775 } else {
776 muted
777 };
778 let pointer = crate::tui::glyphs::selection_marker(is_cursor);
779 let row_y = content
780 .y
781 .saturating_add(u16::try_from(lines.len()).unwrap_or(u16::MAX));
782 lines.push(Line::from(Span::styled(
783 crate::tui::ui_text::semantic_truncate(
784 &format!("{pointer} {}", row.label),
785 width,
786 ),
787 label_style,
788 )));
789 if two_line {
790 lines.push(Line::from(Span::styled(
791 format!(
792 " {}",
793 crate::tui::ui_text::semantic_truncate(
794 &row.hint,
795 width.saturating_sub(4)
796 )
797 ),
798 muted,
799 )));
800 }
801 self.row_hitboxes
802 .borrow_mut()
803 .push((idx, Rect::new(content.x, row_y, content.width, row_height)));
804 }
805 }
806 }
807 Paragraph::new(lines)
808 .wrap(Wrap { trim: false })
809 .render(content, buf);
810 }
811 }
812
813 #[cfg(test)]
814 mod tests {
815 use super::*;
816
817 /// Tuple fields drop in order: restore the environment, then unlock.
818 fn hermetic() -> (
819 crate::test_support::EnvVarGuard,
820 crate::test_support::EnvVarGuard,
821 crate::test_support::TestEnvLock,
822 ) {
823 let lock = crate::test_support::lock_test_env();
824 (
825 crate::test_support::EnvVarGuard::remove("OPENROUTER_API_KEY"),
826 crate::test_support::EnvVarGuard::remove("TYPESAFE_API_KEY"),
827 lock,
828 )
829 }
830
831 fn deepseek_with_openrouter_key(openrouter_key: bool) -> Config {
832 Config {
833 provider: Some("deepseek".to_string()),
834 default_text_model: Some("deepseek-v4-pro".to_string()),
835 providers: Some(crate::config::ProvidersConfig {
836 deepseek: crate::config::ProviderConfig {
837 api_key: Some("ds-test-key".to_string()),
838 ..Default::default()
839 },
840 openrouter: crate::config::ProviderConfig {
841 api_key: openrouter_key.then(|| "or-test-key".to_string()),
842 ..Default::default()
843 },
844 ..Default::default()
845 }),
846 ..Default::default()
847 }
848 }
849
850 #[test]
851 fn router_args_parse_to_requests() {
852 assert_eq!(parse_router_args(None), Ok(RouterRequest::Open));
853 assert_eq!(
854 parse_router_args(Some("jev")),
855 Ok(RouterRequest::Test(RouterPreset::Jev(None)))
856 );
857 assert_eq!(
858 parse_router_args(Some("save jev typesafe")),
859 Ok(RouterRequest::Save(RouterPreset::Jev(Some(
860 DecisionRouterRoute::Typesafe
861 ))))
862 );
863 assert_eq!(
864 parse_router_args(Some("fast")),
865 Ok(RouterRequest::Test(RouterPreset::Fast))
866 );
867 assert!(parse_router_args(Some("jev zai")).is_err());
868 assert!(parse_router_args(Some("turbo")).is_err());
869 }
870
871 #[test]
872 fn presets_derive_from_keys_and_route_capabilities() {
873 let _env = hermetic();
874 let config = deepseek_with_openrouter_key(true);
875 let jev = preset_router_config(&config, RouterPreset::Jev(None), Locale::En)
876 .expect("jev available")
877 .expect("jev writes a table");
878 assert_eq!(jev.kind.as_deref(), Some("decision"));
879 assert_eq!(jev.provider.as_deref(), Some("openrouter"));
880 assert_eq!(jev.model.as_deref(), Some(JEV_OPENROUTER_MODEL));
881 assert_eq!(jev.timeout_secs, Some(2));
882
883 let fast = preset_router_config(&config, RouterPreset::Fast, Locale::En)
884 .expect("fast available")
885 .expect("fast writes a table");
886 assert_eq!(fast.kind.as_deref(), Some("chat"));
887 assert_eq!(fast.provider.as_deref(), Some("deepseek"));
888 assert_eq!(fast.model.as_deref(), Some("deepseek-v4-flash"));
889 assert_eq!(fast.thinking.as_deref(), Some("off"));
890
891 assert!(matches!(
892 preset_router_config(&config, RouterPreset::Off, Locale::En),
893 Ok(None)
894 ));
895
896 // No key anywhere: Jev is unavailable, never guessed.
897 let no_key = deepseek_with_openrouter_key(false);
898 assert!(preset_router_config(&no_key, RouterPreset::Jev(None), Locale::En).is_err());
899 }
900
901 #[test]
902 fn saving_a_preset_writes_exactly_the_router_table() {
903 let _env = hermetic();
904 let dir = tempfile::tempdir().expect("config dir");
905 let path = dir.path().join("config.toml");
906 std::fs::write(
907 &path,
908 "# keep me\nprovider = \"deepseek\"\n\n[auto]\ncost_saving = true\n\n[auto.router]\nprovider = \"zai\"\nmodel = \"glm-5-turbo\"\nthinking = \"low\"\n",
909 )
910 .expect("seed config");
911 let config = deepseek_with_openrouter_key(true);
912 let jev = preset_router_config(&config, RouterPreset::Jev(None), Locale::En)
913 .expect("jev")
914 .expect("table");
915
916 persist_auto_router(Some(&path), Some(&jev)).expect("persist jev");
917 let written = std::fs::read_to_string(&path).expect("read back");
918 assert!(written.contains("# keep me"), "{written}");
919 let reloaded: Config = toml::from_str(&written).expect("reload");
920 let auto = reloaded.auto.expect("[auto]");
921 assert_eq!(auto.cost_saving, Some(true));
922 let router = auto.router.expect("[auto.router]");
923 assert_eq!(router.kind.as_deref(), Some("decision"));
924 assert_eq!(router.provider.as_deref(), Some("openrouter"));
925 assert_eq!(router.model.as_deref(), Some(JEV_OPENROUTER_MODEL));
926 assert_eq!(router.thinking, None, "stale chat keys are replaced");
927 assert_eq!(router.timeout_secs, Some(2));
928 assert_eq!(router.min_confidence, Some(0.5));
929
930 persist_auto_router(Some(&path), None).expect("persist off");
931 let reloaded: Config =
932 toml::from_str(&std::fs::read_to_string(&path).expect("read back")).expect("reload");
933 assert!(reloaded.auto.expect("[auto] kept").router.is_none());
934 }
935
936 #[test]
937 fn confirm_view_saves_only_on_enter() {
938 use crossterm::event::KeyModifiers;
939 let preset = RouterPreset::Jev(Some(DecisionRouterRoute::Openrouter));
940 let mut view = RouterSetupView::confirm(preset, vec!["result".to_string()], Locale::En);
941 match view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) {
942 ViewAction::EmitAndClose(ViewEvent::CommandPaletteSelected {
943 action: CommandPaletteAction::ExecuteCommand { command },
944 }) => assert_eq!(command, "/router save jev openrouter"),
945 other => panic!("expected save command, got {other:?}"),
946 }
947 assert!(matches!(
948 view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
949 ViewAction::Close
950 ));
951 }
952
953 fn rendered(view: &RouterSetupView, width: u16, height: u16) -> String {
954 let area = Rect::new(0, 0, width, height);
955 let mut buf = Buffer::empty(area);
956 view.render(area, &mut buf);
957 (0..height)
958 .map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>())
959 .collect::<Vec<_>>()
960 .join("\n")
961 }
962
963 #[test]
964 fn compact_picker_keeps_the_focused_row_visible_and_clickable() {
965 use crossterm::event::KeyModifiers;
966 let _env = hermetic();
967 let mut view = RouterSetupView::picker(&deepseek_with_openrouter_key(true), Locale::En);
968 let last = view.rows.len() - 1;
969 for _ in 0..last {
970 view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
971 }
972 assert_eq!(view.cursor, last);
973 let text = rendered(&view, 40, 12);
974 let custom = tr(Locale::En, MessageId::RouterPresetCustomLabel);
975 assert!(
976 text.contains(custom.as_ref()),
977 "focused row hidden:\n{text}"
978 );
979 let hitboxes = view.row_hitboxes.borrow();
980 assert!(
981 hitboxes.iter().any(|(index, _)| *index == last),
982 "focused row has no hitbox"
983 );
984 assert!(hitboxes.iter().all(|(_, rect)| rect.bottom() <= 12));
985 drop(hitboxes);
986
987 // A roomy frame still shows every row with its hint.
988 let roomy = rendered(&view, 100, 40);
989 assert!(roomy.contains(tr(Locale::En, MessageId::RouterPresetCustomHint).as_ref()));
990 assert_eq!(view.row_hitboxes.borrow().len(), view.rows.len());
991 }
992
993 #[test]
994 fn off_hint_names_the_cost_saving_fallback() {
995 let _env = hermetic();
996 let mut config = deepseek_with_openrouter_key(true);
997 let off = |config: &Config| {
998 RouterSetupView::picker(config, Locale::En)
999 .rows
1000 .into_iter()
1001 .find(|row| row.preset == RouterPreset::Off)
1002 .expect("off row")
1003 .hint
1004 };
1005 assert_eq!(off(&config), tr(Locale::En, MessageId::RouterPresetOffHint));
1006 config.auto = Some(AutoConfig {
1007 cost_saving: Some(true),
1008 ..Default::default()
1009 });
1010 assert_eq!(
1011 off(&config),
1012 tr(Locale::En, MessageId::RouterPresetOffCostSavingHint)
1013 );
1014 }
1015
1016 #[test]
1017 fn picker_and_reasons_follow_the_ui_locale() {
1018 let _env = hermetic();
1019 let config = deepseek_with_openrouter_key(false);
1020 let view = RouterSetupView::picker(&config, Locale::Ja);
1021 let fast = view
1022 .rows
1023 .iter()
1024 .find(|row| row.preset == RouterPreset::Fast)
1025 .expect("fast row");
1026 assert_eq!(fast.label, tr(Locale::Ja, MessageId::RouterPresetFastLabel));
1027 assert_ne!(fast.label, "Fast tier");
1028 let english = ["Current:", "no OpenRouter API key", "Custom"];
1029 let rendered: Vec<&str> = std::iter::once(view.current.as_str())
1030 .chain(
1031 view.rows
1032 .iter()
1033 .flat_map(|row| [row.label.as_str(), row.hint.as_str()]),
1034 )
1035 .collect();
1036 for text in &rendered {
1037 for word in english {
1038 assert!(
1039 !text.contains(word),
1040 "{word:?} left in Japanese view: {text}"
1041 );
1042 }
1043 }
1044 let reason = preset_router_config(&config, RouterPreset::Jev(None), Locale::Ja)
1045 .expect_err("no decision key");
1046 assert_eq!(reason, tr(Locale::Ja, MessageId::RouterNeedKey));
1047 }
1048 }
1049
1049 lines RUST