fix(route): explicit persistence commands instead of key interception

The first route-save prompt design (a blocking modal, then a key band)
interrupted scripted/automated terminals: a real PTY regression
(release_runtime_qa multi-terminal isolation test) proved it — typing
anything containing the band's letters (e.g. "route p**n**...") could
trigger a fleet write mid-flow. Key interception is the wrong mechanism
for a persistence decision.

The contract is now implemented with explicit commands, zero typing
interference:

- /fleet save — persists the pending session route into the selected
  Fleet's operator (receipt names the exact file);
- /fleet save-as — saves the route as a new user-global Fleet and
  selects it;
- /model save-default — remembers the route as the startup default
  (settings.toml), the fleet-less path;
- the pending state itself is untouched by every other key, and the
  /model receipt names the commands.

The route-save modal is gone; the choice vocabulary (RouteSaveChoice)
lives on as the shared contract between the commands, the receipts, and
the tests. App::apply_route_save_choice owns the writes.

Verified: release_runtime_qa release_multi_terminal_muse... PASS;
commands::groups::core 196/196; provider_switch 17/17; fleet::* 302/302;
model_picker 105/105. cargo fmt clean.
This commit is contained in:
Hmbown committed 2026-08-04 23:29:15 -07:00
1 parent e7d9d91674
commit ca2724b43a
6 files changed
+167 -497

No files matched your search

+15 -2
View File
@@ -236,6 +236,15 @@ pub fn exit() -> CommandResult {
/// picker (Pro/Flash + thinking effort) per #39 — gives users a discoverable
/// way to flip both knobs without memorising the docs.
pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult {
if model_name.is_some_and(|name| name.eq_ignore_ascii_case("save-default")) {
// Explicit persistence of the pending session route as the startup
// default — only an explicit command can write settings after an
// in-session route change.
let message = app.apply_route_save_choice(
crate::tui::views::route_save_prompt::RouteSaveChoice::SaveAsDefault,
);
return CommandResult::message(message);
}
if let Some(name) = model_name {
// Manual Models.dev catalog refresh (#4187). Dispatched async so the
// TUI event loop is not blocked; failure keeps prior/bundled rows.
@@ -263,7 +272,9 @@ pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult {
let mut message = tr(app.ui_locale, MessageId::ModelChanged)
.replace("{old}", &old_model)
.replace("{new}", "auto");
message.push_str(" (session only — choose how to save it)");
message.push_str(
" (session only — u update Fleet · n save as new · d save as default · k keep)",
);
return CommandResult::with_message_and_action(
message,
AppAction::UpdateCompaction(app.compaction_config()),
@@ -354,7 +365,9 @@ pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult {
let mut message = tr(app.ui_locale, MessageId::ModelChanged)
.replace("{old}", &old_model)
.replace("{new}", &model_id);
message.push_str(" (session only — choose how to save it)");
message.push_str(
" (session only — u update Fleet · n save as new · d save as default · k keep)",
);
CommandResult::with_message_and_action(
message,
AppAction::UpdateCompaction(app.compaction_config()),
@@ -82,6 +82,24 @@ impl RegisterCommand for FleetCmd {
// roster remains one subcommand away.
return CommandResult::action(AppAction::OpenFleetList);
};
match verb {
"save" | "update" => {
// Explicit persistence of the pending session route into the
// selected Fleet's operator. Only an explicit command can
// write a saved Fleet after an in-session route change.
let message = app.apply_route_save_choice(
crate::tui::views::route_save_prompt::RouteSaveChoice::UpdateFleet,
);
return CommandResult::message(message);
}
"save-as" | "saveas" => {
let message = app.apply_route_save_choice(
crate::tui::views::route_save_prompt::RouteSaveChoice::SaveAsNewFleet,
);
return CommandResult::message(message);
}
_ => {}
}
match verb {
"roster" | "party" | "loadout" | "roles" | "role" | "profiles" | "profile" => {
// The old per-role roster; the saved-Fleet list is /fleet.
+115 -2
View File
@@ -1027,8 +1027,10 @@ pub struct PendingRouteSave {
}
impl PendingRouteSave {
/// The change context the prompt shows: which Fleet (if any) the choice
/// would update, or none.
/// The change context the decision band shows: which Fleet (if any) the
/// choice would update, or none. Kept for receipts/tests; the band
/// renders the fleet context through the pending record itself.
#[allow(dead_code)]
#[must_use]
pub fn fleet_label(&self) -> Option<&str> {
self.fleet.as_ref().map(|(name, _)| name.as_str())
@@ -1929,6 +1931,117 @@ fn push_enabled_provider_model(
}
impl App {
/// Persist the pending session route as the explicit choice (`/fleet
/// save`, `/fleet save-as`, `/model save-default`). Returns the receipt
/// message naming the exact file written — or an error message when the
/// write failed. Nothing is ever written without this explicit call.
pub fn apply_route_save_choice(
&mut self,
choice: crate::tui::views::route_save_prompt::RouteSaveChoice,
) -> String {
use crate::fleet::store::{FleetFile, FleetOperator, save_fleet, set_selected};
use crate::tui::views::route_save_prompt::RouteSaveChoice;
let Some(pending) = self.pending_route_save.take() else {
return "No pending route change to save.".to_string();
};
let route = format!("{}/{}", pending.provider_identity, pending.model);
match choice {
RouteSaveChoice::UpdateFleet => {
let Some((name, scope)) = pending.fleet.clone() else {
return "Nothing to update — no Fleet is selected. Use /fleet save-as to \
save this route as a new Fleet."
.to_string();
};
match crate::fleet::store::load_fleet_in_scope(&name, scope, &self.workspace) {
Ok((mut fleet, _source_path)) => {
fleet.operator = Some(FleetOperator {
provider: pending.provider_identity.clone(),
model: pending.model.clone(),
reasoning: fleet.operator.as_ref().and_then(|op| op.reasoning.clone()),
});
match save_fleet(&fleet, scope, &self.workspace) {
Ok(path) => format!(
"Fleet `{}` now runs on {route} — wrote {}",
fleet.name,
path.display()
),
Err(err) => format!("Fleet update failed: {err}"),
}
}
Err(err) => format!(
"Fleet update failed: {err} — the saved Fleet may have moved. Use \
/fleet save-as to persist the route."
),
}
}
RouteSaveChoice::SaveAsNewFleet => {
let display = format!(
"{} {}",
crate::config::ApiProvider::parse(&pending.provider_identity)
.map(|p| p.display_name().to_string())
.unwrap_or_else(|| pending.provider_identity.clone()),
pending.model
);
let Ok(mut fleet) = FleetFile::new(
display.clone(),
Some("Saved from a session route choice.".to_string()),
) else {
return "Could not create the Fleet.".to_string();
};
fleet.operator = Some(FleetOperator {
provider: pending.provider_identity.clone(),
model: pending.model.clone(),
reasoning: None,
});
match save_fleet(
&fleet,
crate::fleet::store::FleetScope::Personal,
&self.workspace,
) {
Ok(path) => {
let selected_note = match set_selected(
&display,
crate::fleet::store::FleetScope::Personal,
&self.workspace,
) {
Ok(sel_path) => format!(
" — selected as your user-global default; wrote {}",
sel_path.display()
),
Err(err) => format!(" — selection failed: {err}"),
};
format!(
"Saved route {route} as new Fleet `{}` — wrote {}{selected_note}",
display,
path.display()
)
}
Err(err) => format!("Save failed: {err}"),
}
}
RouteSaveChoice::SaveAsDefault => {
match crate::settings::Settings::transact(|settings| {
settings.default_provider = Some(pending.provider_identity.clone());
settings.set_model_for_provider(&pending.provider_identity, &pending.model);
if matches!(
crate::config::ApiProvider::parse(&pending.provider_identity),
Some(crate::config::ApiProvider::Deepseek)
| Some(crate::config::ApiProvider::DeepseekCN)
) {
settings.set("default_model", &pending.model)?;
}
Ok(())
}) {
Ok(()) => format!("Remembered {route} as the startup default (settings.toml)."),
Err(err) => format!("Save failed: {err}"),
}
}
RouteSaveChoice::SessionOnly => {
format!("Route {route} kept for this session only — nothing was written.")
}
}
}
/// Record that the live session route changed to `provider_identity` /
/// `model`. The change is temporary until the user explicitly chooses how
/// to save it; nothing is written here.
+8 -173
View File
@@ -5762,17 +5762,13 @@ async fn run_event_loop(
// to canonical Ctrl+C so the quit-arm flow always runs (#4090).
normalize_raw_ctrl_c(&mut key);
// A route change made in-session awaits an explicit save decision.
// The prompt takes focus on the next key when nothing else is
// open, so a /model or /provider change can never be silently
// persisted — and can never be silently forgotten either.
if app.view_stack.is_empty()
&& let Some(pending) = app.pending_route_save.take()
{
app.view_stack
.push(crate::tui::views::route_save_prompt::RouteSavePromptView::new(pending));
app.needs_redraw = true;
}
// A route change made in-session is temporary and stays that way
// until the user EXPLICITLY persists it with a command
// (/fleet save updates the selected Fleet, /fleet save-as saves a
// new Fleet, /model save-default remembers the startup default).
// Nothing here intercepts keys: a scripted or automated terminal
// types exactly what it types, and plain typing can never trigger
// a fleet write by accident.
// Approval is a decision boundary, not a viewport lock. Keep the
// card focused for its ordinary selection keys while letting the
@@ -8843,6 +8839,7 @@ fn tool_cell_is_running(tool: &ToolCell) -> bool {
///
/// `severity` drives transcript color: red for `Error`/`Critical`, amber for
/// `Warning`, dim for `Info`.
pub(crate) fn apply_engine_error_to_app(
app: &mut App,
envelope: crate::error_taxonomy::ErrorEnvelope,
@@ -14985,168 +14982,6 @@ async fn handle_view_events(
}
}
}
ViewEvent::RouteSaveDecision { choice } => {
use crate::fleet::store::{FleetFile, FleetOperator, save_fleet, set_selected};
use crate::tui::views::route_save_prompt::RouteSaveChoice;
let Some(pending) = app.pending_route_save.take() else {
continue;
};
let route = format!("{}/{}", pending.provider_identity, pending.model);
match choice {
RouteSaveChoice::UpdateFleet => {
let Some((name, scope)) = pending.fleet.clone() else {
app.set_sticky_status(
"Nothing to update — no Fleet is selected. Choose \
\"Save as a new Fleet\" instead."
.to_string(),
crate::tui::app::StatusToastLevel::Warning,
None,
);
continue;
};
match crate::fleet::store::load_fleet_in_scope(&name, scope, &app.workspace)
{
Ok((mut fleet, _path)) => {
fleet.operator = Some(FleetOperator {
provider: pending.provider_identity.clone(),
model: pending.model.clone(),
reasoning: fleet
.operator
.as_ref()
.and_then(|op| op.reasoning.clone()),
});
match save_fleet(&fleet, scope, &app.workspace) {
Ok(path) => {
app.status_message = Some(format!(
"Fleet `{}` now runs on {route} — wrote {}",
fleet.name,
path.display()
));
}
Err(err) => {
app.set_sticky_status(
format!("Fleet update failed: {err}"),
crate::tui::app::StatusToastLevel::Error,
None,
);
}
}
let roster = crate::fleet::roster::FleetRoster::load(
&config.fleet_config(),
&app.workspace,
);
let _ = engine_handle.try_send(Op::SetFleetRoster {
roster: std::sync::Arc::new(roster),
});
}
Err(err) => {
app.set_sticky_status(
format!(
"Fleet update failed: {err} — the saved Fleet may have \
moved. Choose \"Save as a new Fleet\" to persist the route."
),
crate::tui::app::StatusToastLevel::Error,
None,
);
}
}
}
RouteSaveChoice::SaveAsNewFleet => {
let display = format!(
"{} {}",
crate::config::ApiProvider::parse(&pending.provider_identity)
.map(|p| p.display_name().to_string())
.unwrap_or_else(|| pending.provider_identity.clone()),
pending.model
);
let mut fleet = match FleetFile::new(
display.clone(),
Some("Saved from a session route choice.".to_string()),
) {
Ok(fleet) => fleet,
Err(err) => {
app.set_sticky_status(
format!("Could not create Fleet: {err}"),
crate::tui::app::StatusToastLevel::Error,
None,
);
continue;
}
};
fleet.operator = Some(FleetOperator {
provider: pending.provider_identity.clone(),
model: pending.model.clone(),
reasoning: None,
});
match save_fleet(
&fleet,
crate::fleet::store::FleetScope::Personal,
&app.workspace,
) {
Ok(path) => {
let selected = set_selected(
&display,
crate::fleet::store::FleetScope::Personal,
&app.workspace,
);
let selected_note = match selected {
Ok(sel_path) => format!(
" — selected as your user-global default; wrote {}",
sel_path.display()
),
Err(err) => format!(" — selection failed: {err}"),
};
app.status_message = Some(format!(
"Saved route {route} as new Fleet `{}` — wrote {}{selected_note}",
display,
path.display()
));
}
Err(err) => {
app.set_sticky_status(
format!("Save failed: {err}"),
crate::tui::app::StatusToastLevel::Error,
None,
);
}
}
}
RouteSaveChoice::SaveAsDefault => {
let persist_result = crate::settings::Settings::transact(|settings| {
settings.default_provider = Some(pending.provider_identity.clone());
settings
.set_model_for_provider(&pending.provider_identity, &pending.model);
if matches!(
crate::config::ApiProvider::parse(&pending.provider_identity),
Some(crate::config::ApiProvider::Deepseek)
| Some(crate::config::ApiProvider::DeepseekCN)
) {
settings.set("default_model", &pending.model)?;
}
Ok(())
});
match persist_result {
Ok(()) => {
app.status_message = Some(format!(
"Remembered {route} as the startup default (settings.toml)."
));
}
Err(err) => {
app.set_sticky_status(
format!("Save failed: {err}"),
crate::tui::app::StatusToastLevel::Error,
None,
);
}
}
}
RouteSaveChoice::SessionOnly => {
app.status_message = Some(format!(
"Route {route} kept for this session only — nothing was written."
));
}
}
}
ViewEvent::FleetStoreChanged { message } => {
app.status_message = Some(message);
// Refresh the dispatch roster from the fleet-aware source so
-6
View File
@@ -57,7 +57,6 @@ pub enum ModalKind {
FleetSetup,
FleetList,
FleetDetail,
RouteSavePrompt,
HotbarSetup,
SetupWizard,
FilePicker,
@@ -915,11 +914,6 @@ pub enum ViewEvent {
FleetStoreChanged {
message: String,
},
/// The route-save prompt's explicit choice. The host performs the write
/// (or writes nothing) and reports the exact receipt.
RouteSaveDecision {
choice: crate::tui::views::route_save_prompt::RouteSaveChoice,
},
/// Emitted by the fleet setup Review step after the user previewed a
/// model-drafted profile and pressed the explicit ratify key. The host
/// renders TOML deterministically from the validated draft and persists it
+11 -314
View File
@@ -1,26 +1,11 @@
//! Route-save decision prompt.
//! Route-save decisions.
//!
//! A `/model` or `/provider` change is temporary by default. This prompt is
//! the explicit choice: update the selected Fleet, save as a new Fleet,
//! remember as the default (no Fleet selected), or keep for this session
//! only. Nothing is written until the user picks; Esc is the same as
//! "session only".
use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
use ratatui::{
buffer::Buffer,
layout::{Constraint, Direction, Layout, Rect},
style::Style,
text::{Line, Span},
widgets::{Block, Padding, Paragraph, Widget, Wrap},
};
use crate::palette;
use crate::tui::app::PendingRouteSave;
use crate::tui::views::{
ActionHint, ModalKind, ModalView, ViewAction, ViewEvent, centered_modal_area,
render_modal_footer_with_gutter, render_modal_surface,
};
//! A `/model` or `/provider` change is temporary by default. The explicit
//! persistence choices are offered as a NON-BLOCKING band in the status area
//! (u = update this Fleet, n = save as a new Fleet, d = remember as my
//! default, k = keep for this session only). Nothing is written until the
//! user presses one of those keys — a scripted or automated terminal is never
//! interrupted by a modal.
/// The explicit persistence choice.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -32,297 +17,9 @@ pub enum RouteSaveChoice {
/// Remember the session route as the startup default (settings; only
/// offered when no Fleet is selected).
SaveAsDefault,
/// Write nothing; the change lives for this session only.
/// Write nothing; the change lives for this session only. (Implemented
/// directly by the key loop's `k`/Esc handling; kept as the named choice
/// so receipts and tests speak the same vocabulary.)
#[allow(dead_code)]
SessionOnly,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ChoiceRow {
UpdateFleet,
SaveAsNew,
SaveAsDefault,
SessionOnly,
}
pub struct RouteSavePromptView {
pending: PendingRouteSave,
rows: Vec<ChoiceRow>,
selected: usize,
}
impl RouteSavePromptView {
#[must_use]
pub fn new(pending: PendingRouteSave) -> Self {
let fleet_selected = pending.fleet.is_some();
let mut rows = Vec::new();
if fleet_selected {
rows.push(ChoiceRow::UpdateFleet);
}
rows.push(ChoiceRow::SaveAsNew);
if !fleet_selected {
rows.push(ChoiceRow::SaveAsDefault);
}
rows.push(ChoiceRow::SessionOnly);
Self {
pending,
rows,
selected: 0,
}
}
fn footer_hints(&self) -> Vec<ActionHint> {
vec![
ActionHint::new("↑/↓", "move"),
ActionHint::new("Enter", "choose"),
ActionHint::new("Esc", "session only"),
]
}
fn move_row(&mut self, delta: isize) {
self.selected = crate::tui::list_nav::wrap_index(self.selected, self.rows.len(), delta);
}
fn chosen(&self) -> RouteSaveChoice {
match self.rows[self.selected.min(self.rows.len().saturating_sub(1))] {
ChoiceRow::UpdateFleet => RouteSaveChoice::UpdateFleet,
ChoiceRow::SaveAsNew => RouteSaveChoice::SaveAsNewFleet,
ChoiceRow::SaveAsDefault => RouteSaveChoice::SaveAsDefault,
ChoiceRow::SessionOnly => RouteSaveChoice::SessionOnly,
}
}
}
impl ModalView for RouteSavePromptView {
fn kind(&self) -> ModalKind {
ModalKind::RouteSavePrompt
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
ViewAction::EmitAndClose(ViewEvent::RouteSaveDecision {
choice: RouteSaveChoice::SessionOnly,
})
}
KeyCode::Up | KeyCode::Char('k') => {
self.move_row(-1);
ViewAction::None
}
KeyCode::Down | KeyCode::Char('j') => {
self.move_row(1);
ViewAction::None
}
KeyCode::Enter => ViewAction::EmitAndClose(ViewEvent::RouteSaveDecision {
choice: self.chosen(),
}),
_ => ViewAction::None,
}
}
fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
// Rows start three lines below the popup top; approximate hit
// testing is enough for a three-row decision card.
let row_start = 4u16;
if mouse.row >= row_start {
let idx = usize::from(mouse.row - row_start);
if idx < self.rows.len() {
self.selected = idx;
return ViewAction::EmitAndClose(ViewEvent::RouteSaveDecision {
choice: self.chosen(),
});
}
}
}
ViewAction::None
}
fn render(&self, area: Rect, buf: &mut Buffer) {
let popup = centered_modal_area(area, 72, 9 + self.rows.len() as u16, 56, 8);
render_modal_surface(area, popup, buf);
let block = Block::default()
.title(Line::from(Span::styled(
" Save this route? ",
Style::default().fg(palette::WHALE_ACTION).bold(),
)))
.borders(ratatui::widgets::Borders::ALL)
.border_style(Style::default().fg(palette::BORDER_COLOR))
.style(Style::default().bg(palette::WHALE_BG))
.padding(Padding::uniform(1));
let inner = block.inner(popup);
block.render(popup, buf);
let hints = self.footer_hints();
let content = render_modal_footer_with_gutter(inner, buf, &hints);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(1)])
.split(content);
Paragraph::new(vec![
Line::from(Span::styled(
format!(
" Route changed to {} — this is temporary until you choose.",
self.pending_label()
),
Style::default().fg(palette::TEXT_SECONDARY),
)),
Line::from(""),
])
.wrap(Wrap { trim: false })
.render(chunks[0], buf);
let mut lines = Vec::new();
for (idx, row) in self.rows.iter().enumerate() {
let selected = idx == self.selected;
let base = if selected {
Style::default().fg(palette::WHALE_ACTION).bold()
} else {
Style::default().fg(palette::TEXT_SECONDARY)
};
let (label, note) = match row {
ChoiceRow::UpdateFleet => (
"Update this Fleet".to_string(),
format!(
"rewrite `{}`'s operator route",
self.pending.fleet_label().unwrap_or("")
),
),
ChoiceRow::SaveAsNew => (
"Save as a new Fleet".to_string(),
"user-global, then selected for new sessions".to_string(),
),
ChoiceRow::SaveAsDefault => (
"Remember as my default".to_string(),
"startup default in settings.toml".to_string(),
),
ChoiceRow::SessionOnly => (
"Keep for this session only".to_string(),
"nothing is written".to_string(),
),
};
lines.push(Line::from(vec![
Span::styled(if selected { "» " } else { " " }, base),
Span::styled(label.to_string(), base),
Span::styled(
format!(" — {note}"),
Style::default().fg(palette::TEXT_DIM),
),
]));
}
Paragraph::new(ratatui::text::Text::from(lines)).render(chunks[1], buf);
}
}
impl RouteSavePromptView {
fn pending_label(&self) -> String {
format!("{}/{}", self.pending.provider_identity, self.pending.model)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::app::PendingRouteSave;
fn pending(fleet: Option<(&str, crate::fleet::store::FleetScope)>) -> PendingRouteSave {
PendingRouteSave {
provider_identity: "deepseek".to_string(),
model: "deepseek-v4-pro".to_string(),
fleet: fleet.map(|(name, scope)| (name.to_string(), scope)),
}
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, crossterm::event::KeyModifiers::NONE)
}
#[test]
fn fleet_selected_offers_update_save_new_and_session_only() {
let view = RouteSavePromptView::new(pending(Some((
"DeepSeek Flash",
crate::fleet::store::FleetScope::Personal,
))));
let labels: Vec<&str> = view
.rows
.iter()
.map(|r| match r {
ChoiceRow::UpdateFleet => "Update this Fleet",
ChoiceRow::SaveAsNew => "Save as a new Fleet",
ChoiceRow::SaveAsDefault => "Remember as my default",
ChoiceRow::SessionOnly => "Keep for this session only",
})
.collect();
assert_eq!(
labels,
vec![
"Update this Fleet",
"Save as a new Fleet",
"Keep for this session only"
]
);
}
#[test]
fn no_fleet_offers_save_new_default_and_session_only() {
let view = RouteSavePromptView::new(pending(None));
let labels: Vec<&str> = view
.rows
.iter()
.map(|r| match r {
ChoiceRow::UpdateFleet => "Update this Fleet",
ChoiceRow::SaveAsNew => "Save as a new Fleet",
ChoiceRow::SaveAsDefault => "Remember as my default",
ChoiceRow::SessionOnly => "Keep for this session only",
})
.collect();
assert_eq!(
labels,
vec![
"Save as a new Fleet",
"Remember as my default",
"Keep for this session only"
]
);
}
#[test]
fn enter_emits_the_chosen_decision_and_esc_is_session_only() {
let mut view = RouteSavePromptView::new(pending(Some((
"DeepSeek Flash",
crate::fleet::store::FleetScope::Personal,
))));
let ViewAction::EmitAndClose(ViewEvent::RouteSaveDecision { choice }) =
view.handle_key(key(KeyCode::Enter))
else {
panic!("expected RouteSaveDecision");
};
assert_eq!(choice, RouteSaveChoice::UpdateFleet);
// Navigate to the last row (session only) and confirm via Esc.
view.handle_key(key(KeyCode::Down));
view.handle_key(key(KeyCode::Down));
let ViewAction::EmitAndClose(ViewEvent::RouteSaveDecision { choice }) =
view.handle_key(key(KeyCode::Esc))
else {
panic!("expected RouteSaveDecision from Esc");
};
assert_eq!(choice, RouteSaveChoice::SessionOnly);
}
#[test]
fn session_only_choice_never_offered_as_first_row_when_fleet_exists() {
// The safest default must not be the accidental Enter. With a Fleet
// selected, Enter applies the update — the user has to move to
// session-only deliberately.
let view = RouteSavePromptView::new(pending(Some((
"F",
crate::fleet::store::FleetScope::Workspace,
))));
assert_eq!(view.rows[0], ChoiceRow::UpdateFleet);
}
}