mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 09:59:45 -10:00
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:
1 parent
e7d9d91674
commit
ca2724b43a
6 files changed
+167
-497
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user