fix(tui): localized, level-typed picker receipts; exact custom-provider pins; honest Pin/Fleet hints

- Pin/unpin/reorder/refresh receipts and the `pinned` chip go through
  MessageIds (ModelPicker*) in every locale pack.
- The picker notice carries StatusToastLevel and renders through
  palette::chrome_style(theme, level.ink()): failures use the failure slot,
  successes the outcome slot, Fleet no-ops the info slot.
- pin_names_row compares named custom provider identities exactly; only
  built-in slugs stay case-insensitive.
- ⇧P / ⇧F footer hints show only when highlighted_route() is Some.

Tests: codewhale-tui --lib model_picker picker pin — 509 passed, 1 failed
(tools::subagent::tests::xai_pin_without_credentials_falls_back_to_the_session_route_loudly,
unrelated; passes alone: 2 passed); codewhale-localization — 50 passed;
locale parity PASS.

Claude-Session: https://claude.ai/code/session_01Vjkk8uP5v3qYaBPonDEzBc
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-24 20:41:17 -07:00
1 parent a46aff0ee0
commit f04f8a5916
19 files changed
+350 -61

No files matched your search

+9 -1
View File
@@ -2340,5 +2340,13 @@
"WorkflowSettledOfTotal": "{done}/{total} fets",
"WorkflowPhaseCountOne": "{count} fase",
"WorkflowPhaseCountMany": "{count} fases",
"WorkflowPhaseOrdinal": "fase {n}/{total}"
"WorkflowPhaseOrdinal": "fase {n}/{total}",
"ModelPickerPinned": "S'ha fixat {route}",
"ModelPickerUnpinned": "S'ha deixat de fixar {route}",
"ModelPickerPinFailed": "No s'ha pogut actualitzar la fixació: {error}",
"ModelPickerPinOrderUpdated": "S'ha actualitzat l'ordre dels models fixats",
"ModelPickerPinReorderFailed": "No s'ha pogut reordenar la fixació: {error}",
"ModelPickerReadinessRefreshed": "S'ha actualitzat la disponibilitat dels models · files del catàleg reconstruïdes",
"ModelPickerOpenToRefresh": "Obre /model per actualitzar la disponibilitat i el catàleg",
"ModelPickerPinnedChip": "fixat"
}
+9 -1
View File
@@ -2340,5 +2340,13 @@
"WorkflowSettledOfTotal": "{done}/{total} fertig",
"WorkflowPhaseCountOne": "{count} Phase",
"WorkflowPhaseCountMany": "{count} Phasen",
"WorkflowPhaseOrdinal": "Phase {n}/{total}"
"WorkflowPhaseOrdinal": "Phase {n}/{total}",
"ModelPickerPinned": "{route} angeheftet",
"ModelPickerUnpinned": "{route} gelöst",
"ModelPickerPinFailed": "Anheften konnte nicht aktualisiert werden: {error}",
"ModelPickerPinOrderUpdated": "Reihenfolge der angehefteten Modelle aktualisiert",
"ModelPickerPinReorderFailed": "Anheftung konnte nicht verschoben werden: {error}",
"ModelPickerReadinessRefreshed": "Modellbereitschaft aktualisiert · Katalogzeilen neu aufgebaut",
"ModelPickerOpenToRefresh": "Öffne /model, um Bereitschaft und Katalog zu aktualisieren",
"ModelPickerPinnedChip": "angeheftet"
}
+9 -1
View File
@@ -2340,5 +2340,13 @@
"WorkflowSettledOfTotal": "{done}/{total} done",
"WorkflowPhaseCountOne": "{count} phase",
"WorkflowPhaseCountMany": "{count} phases",
"WorkflowPhaseOrdinal": "phase {n}/{total}"
"WorkflowPhaseOrdinal": "phase {n}/{total}",
"ModelPickerPinned": "Pinned {route}",
"ModelPickerUnpinned": "Unpinned {route}",
"ModelPickerPinFailed": "Could not update pin: {error}",
"ModelPickerPinOrderUpdated": "Pinned model order updated",
"ModelPickerPinReorderFailed": "Could not reorder pin: {error}",
"ModelPickerReadinessRefreshed": "Model readiness refreshed · catalog rows rebuilt",
"ModelPickerOpenToRefresh": "Open /model to refresh readiness and catalog",
"ModelPickerPinnedChip": "pinned"
}
+9 -1
View File
@@ -2340,5 +2340,13 @@
"WorkflowSettledOfTotal": "{done}/{total} listos",
"WorkflowPhaseCountOne": "{count} fase",
"WorkflowPhaseCountMany": "{count} fases",
"WorkflowPhaseOrdinal": "fase {n}/{total}"
"WorkflowPhaseOrdinal": "fase {n}/{total}",
"ModelPickerPinned": "Se fijó {route}",
"ModelPickerUnpinned": "Se dejó de fijar {route}",
"ModelPickerPinFailed": "No se pudo actualizar la fijación: {error}",
"ModelPickerPinOrderUpdated": "Se actualizó el orden de los modelos fijados",
"ModelPickerPinReorderFailed": "No se pudo reordenar la fijación: {error}",
"ModelPickerReadinessRefreshed": "Disponibilidad de modelos actualizada · filas del catálogo reconstruidas",
"ModelPickerOpenToRefresh": "Abre /model para actualizar la disponibilidad y el catálogo",
"ModelPickerPinnedChip": "fijado"
}
+9 -1
View File
@@ -2340,5 +2340,13 @@
"WorkflowSettledOfTotal": "{done}/{total} terminés",
"WorkflowPhaseCountOne": "{count} phase",
"WorkflowPhaseCountMany": "{count} phases",
"WorkflowPhaseOrdinal": "phase {n}/{total}"
"WorkflowPhaseOrdinal": "phase {n}/{total}",
"ModelPickerPinned": "{route} épinglé",
"ModelPickerUnpinned": "{route} désépinglé",
"ModelPickerPinFailed": "Impossible de mettre à jour l’épingle : {error}",
"ModelPickerPinOrderUpdated": "Ordre des modèles épinglés mis à jour",
"ModelPickerPinReorderFailed": "Impossible de réordonner l’épingle : {error}",
"ModelPickerReadinessRefreshed": "Disponibilité des modèles actualisée · lignes du catalogue reconstruites",
"ModelPickerOpenToRefresh": "Ouvrez /model pour actualiser la disponibilité et le catalogue",
"ModelPickerPinnedChip": "épinglé"
}
+9 -1
View File
@@ -2340,5 +2340,13 @@
"WorkflowSettledOfTotal": "{done}/{total} पूरे",
"WorkflowPhaseCountOne": "{count} चरण",
"WorkflowPhaseCountMany": "{count} चरण",
"WorkflowPhaseOrdinal": "चरण {n}/{total}"
"WorkflowPhaseOrdinal": "चरण {n}/{total}",
"ModelPickerPinned": "{route} पिन किया गया",
"ModelPickerUnpinned": "{route} का पिन हटाया गया",
"ModelPickerPinFailed": "पिन अपडेट नहीं हो सका: {error}",
"ModelPickerPinOrderUpdated": "पिन किए गए मॉडलों का क्रम अपडेट हुआ",
"ModelPickerPinReorderFailed": "पिन का क्रम नहीं बदला जा सका: {error}",
"ModelPickerReadinessRefreshed": "मॉडल की तैयारी रीफ़्रेश हुई · कैटलॉग पंक्तियाँ फिर से बनीं",
"ModelPickerOpenToRefresh": "तैयारी और कैटलॉग रीफ़्रेश करने के लिए /model खोलें",
"ModelPickerPinnedChip": "पिन किया"
}
+9 -1
View File
@@ -2340,5 +2340,13 @@
"WorkflowSettledOfTotal": "{done}/{total} selesai",
"WorkflowPhaseCountOne": "{count} fase",
"WorkflowPhaseCountMany": "{count} fase",
"WorkflowPhaseOrdinal": "fase {n}/{total}"
"WorkflowPhaseOrdinal": "fase {n}/{total}",
"ModelPickerPinned": "{route} disematkan",
"ModelPickerUnpinned": "Sematan {route} dilepas",
"ModelPickerPinFailed": "Tidak dapat memperbarui sematan: {error}",
"ModelPickerPinOrderUpdated": "Urutan model yang disematkan diperbarui",
"ModelPickerPinReorderFailed": "Tidak dapat mengubah urutan sematan: {error}",
"ModelPickerReadinessRefreshed": "Kesiapan model diperbarui · baris katalog dibangun ulang",
"ModelPickerOpenToRefresh": "Buka /model untuk memperbarui kesiapan dan katalog",
"ModelPickerPinnedChip": "disematkan"
}
+9 -1
View File
@@ -2340,5 +2340,13 @@
"WorkflowSettledOfTotal": "{done}/{total} 完了",
"WorkflowPhaseCountOne": "{count} フェーズ",
"WorkflowPhaseCountMany": "{count} フェーズ",
"WorkflowPhaseOrdinal": "フェーズ {n}/{total}"
"WorkflowPhaseOrdinal": "フェーズ {n}/{total}",
"ModelPickerPinned": "{route} をピン留めしました",
"ModelPickerUnpinned": "{route} のピン留めを外しました",
"ModelPickerPinFailed": "ピン留めを更新できませんでした: {error}",
"ModelPickerPinOrderUpdated": "ピン留めしたモデルの順序を更新しました",
"ModelPickerPinReorderFailed": "ピン留めの順序を変更できませんでした: {error}",
"ModelPickerReadinessRefreshed": "モデルの準備状況を更新しました · カタログ行を再構築しました",
"ModelPickerOpenToRefresh": "準備状況とカタログを更新するには /model を開いてください",
"ModelPickerPinnedChip": "ピン留め"
}
+9 -1
View File
@@ -2340,5 +2340,13 @@
"WorkflowSettledOfTotal": "{done}/{total} 완료",
"WorkflowPhaseCountOne": "{count}개 단계",
"WorkflowPhaseCountMany": "{count}개 단계",
"WorkflowPhaseOrdinal": "단계 {n}/{total}"
"WorkflowPhaseOrdinal": "단계 {n}/{total}",
"ModelPickerPinned": "{route} 고정됨",
"ModelPickerUnpinned": "{route} 고정 해제됨",
"ModelPickerPinFailed": "고정을 업데이트하지 못했습니다: {error}",
"ModelPickerPinOrderUpdated": "고정된 모델 순서를 업데이트했습니다",
"ModelPickerPinReorderFailed": "고정 순서를 바꾸지 못했습니다: {error}",
"ModelPickerReadinessRefreshed": "모델 준비 상태를 새로 고쳤습니다 · 카탈로그 행을 다시 만들었습니다",
"ModelPickerOpenToRefresh": "준비 상태와 카탈로그를 새로 고치려면 /model을 여세요",
"ModelPickerPinnedChip": "고정됨"
}
+9 -1
View File
@@ -2340,5 +2340,13 @@
"WorkflowSettledOfTotal": "{done}/{total} concluídos",
"WorkflowPhaseCountOne": "{count} fase",
"WorkflowPhaseCountMany": "{count} fases",
"WorkflowPhaseOrdinal": "fase {n}/{total}"
"WorkflowPhaseOrdinal": "fase {n}/{total}",
"ModelPickerPinned": "{route} fixado",
"ModelPickerUnpinned": "{route} desafixado",
"ModelPickerPinFailed": "Não foi possível atualizar a fixação: {error}",
"ModelPickerPinOrderUpdated": "Ordem dos modelos fixados atualizada",
"ModelPickerPinReorderFailed": "Não foi possível reordenar a fixação: {error}",
"ModelPickerReadinessRefreshed": "Prontidão dos modelos atualizada · linhas do catálogo reconstruídas",
"ModelPickerOpenToRefresh": "Abra /model para atualizar a prontidão e o catálogo",
"ModelPickerPinnedChip": "fixado"
}
+9 -1
View File
@@ -2340,5 +2340,13 @@
"WorkflowSettledOfTotal": "{done}/{total} готово",
"WorkflowPhaseCountOne": "фаз: {count}",
"WorkflowPhaseCountMany": "фаз: {count}",
"WorkflowPhaseOrdinal": "фаза {n}/{total}"
"WorkflowPhaseOrdinal": "фаза {n}/{total}",
"ModelPickerPinned": "{route} закреплено",
"ModelPickerUnpinned": "{route} откреплено",
"ModelPickerPinFailed": "Не удалось обновить закрепление: {error}",
"ModelPickerPinOrderUpdated": "Порядок закреплённых моделей обновлён",
"ModelPickerPinReorderFailed": "Не удалось изменить порядок закрепления: {error}",
"ModelPickerReadinessRefreshed": "Готовность моделей обновлена · строки каталога перестроены",
"ModelPickerOpenToRefresh": "Откройте /model, чтобы обновить готовность и каталог",
"ModelPickerPinnedChip": "закреплено"
}
+9 -1
View File
@@ -2340,5 +2340,13 @@
"WorkflowSettledOfTotal": "{done}/{total} готово",
"WorkflowPhaseCountOne": "фаз: {count}",
"WorkflowPhaseCountMany": "фаз: {count}",
"WorkflowPhaseOrdinal": "фаза {n}/{total}"
"WorkflowPhaseOrdinal": "фаза {n}/{total}",
"ModelPickerPinned": "{route} закріплено",
"ModelPickerUnpinned": "{route} відкріплено",
"ModelPickerPinFailed": "Не вдалося оновити закріплення: {error}",
"ModelPickerPinOrderUpdated": "Порядок закріплених моделей оновлено",
"ModelPickerPinReorderFailed": "Не вдалося змінити порядок закріплення: {error}",
"ModelPickerReadinessRefreshed": "Готовність моделей оновлено · рядки каталогу перебудовано",
"ModelPickerOpenToRefresh": "Відкрийте /model, щоб оновити готовність і каталог",
"ModelPickerPinnedChip": "закріплено"
}
+9 -1
View File
@@ -2340,5 +2340,13 @@
"WorkflowSettledOfTotal": "{done}/{total} xong",
"WorkflowPhaseCountOne": "{count} giai đoạn",
"WorkflowPhaseCountMany": "{count} giai đoạn",
"WorkflowPhaseOrdinal": "giai đoạn {n}/{total}"
"WorkflowPhaseOrdinal": "giai đoạn {n}/{total}",
"ModelPickerPinned": "Đã ghim {route}",
"ModelPickerUnpinned": "Đã bỏ ghim {route}",
"ModelPickerPinFailed": "Không thể cập nhật ghim: {error}",
"ModelPickerPinOrderUpdated": "Đã cập nhật thứ tự mô hình đã ghim",
"ModelPickerPinReorderFailed": "Không thể sắp xếp lại ghim: {error}",
"ModelPickerReadinessRefreshed": "Đã làm mới trạng thái sẵn sàng của mô hình · đã dựng lại các dòng danh mục",
"ModelPickerOpenToRefresh": "Mở /model để làm mới trạng thái sẵn sàng và danh mục",
"ModelPickerPinnedChip": "đã ghim"
}
+9 -1
View File
@@ -2340,5 +2340,13 @@
"WorkflowSettledOfTotal": "{done}/{total} 已完成",
"WorkflowPhaseCountOne": "{count} 个阶段",
"WorkflowPhaseCountMany": "{count} 个阶段",
"WorkflowPhaseOrdinal": "阶段 {n}/{total}"
"WorkflowPhaseOrdinal": "阶段 {n}/{total}",
"ModelPickerPinned": "已固定 {route}",
"ModelPickerUnpinned": "已取消固定 {route}",
"ModelPickerPinFailed": "无法更新固定:{error}",
"ModelPickerPinOrderUpdated": "已更新固定模型的顺序",
"ModelPickerPinReorderFailed": "无法调整固定顺序:{error}",
"ModelPickerReadinessRefreshed": "已刷新模型就绪状态 · 已重建目录行",
"ModelPickerOpenToRefresh": "打开 /model 以刷新就绪状态和目录",
"ModelPickerPinnedChip": "已固定"
}
+9 -1
View File
@@ -2340,5 +2340,13 @@
"WorkflowSettledOfTotal": "{done}/{total} 已完成",
"WorkflowPhaseCountOne": "{count} 個階段",
"WorkflowPhaseCountMany": "{count} 個階段",
"WorkflowPhaseOrdinal": "階段 {n}/{total}"
"WorkflowPhaseOrdinal": "階段 {n}/{total}",
"ModelPickerPinned": "已釘選 {route}",
"ModelPickerUnpinned": "已取消釘選 {route}",
"ModelPickerPinFailed": "無法更新釘選:{error}",
"ModelPickerPinOrderUpdated": "已更新釘選模型的順序",
"ModelPickerPinReorderFailed": "無法調整釘選順序:{error}",
"ModelPickerReadinessRefreshed": "已重新整理模型就緒狀態 · 已重建目錄列",
"ModelPickerOpenToRefresh": "開啟 /model 以重新整理就緒狀態和目錄",
"ModelPickerPinnedChip": "已釘選"
}
+16
View File
@@ -2562,6 +2562,14 @@ pub enum MessageId {
WorkflowPhaseCountOne,
WorkflowPhaseCountMany,
WorkflowPhaseOrdinal,
ModelPickerPinned,
ModelPickerUnpinned,
ModelPickerPinFailed,
ModelPickerPinOrderUpdated,
ModelPickerPinReorderFailed,
ModelPickerReadinessRefreshed,
ModelPickerOpenToRefresh,
ModelPickerPinnedChip,
}
#[allow(dead_code)]
@@ -4909,6 +4917,14 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::WorkflowPhaseCountOne,
MessageId::WorkflowPhaseCountMany,
MessageId::WorkflowPhaseOrdinal,
MessageId::ModelPickerPinned,
MessageId::ModelPickerUnpinned,
MessageId::ModelPickerPinFailed,
MessageId::ModelPickerPinOrderUpdated,
MessageId::ModelPickerPinReorderFailed,
MessageId::ModelPickerReadinessRefreshed,
MessageId::ModelPickerOpenToRefresh,
MessageId::ModelPickerPinnedChip,
];
pub fn tr(locale: Locale, id: MessageId) -> Cow<'static, str> {
+71 -20
View File
@@ -39,7 +39,7 @@ use crate::provider_lake::{
};
use crate::reasoning_preference::ReasoningEffort;
use crate::settings::PinnedModel;
use crate::tui::app::App;
use crate::tui::app::{App, StatusToastLevel};
use crate::tui::menu_style;
use crate::tui::views::fleet_detail::{FleetRouteSelection, FleetRouteTarget};
use crate::tui::views::{
@@ -266,10 +266,12 @@ pub struct ModelPickerView {
catalog_action_hovered: bool,
purpose: ModelPickerPurpose,
assignment_context: Option<(String, String)>,
/// Receipt of the last ⇧P / ⇧F / refresh, and whether it failed. The
/// Receipt of the last ⇧P / ⇧F / refresh with its typed toast level. The
/// picker covers the status line, so this line is the only place those
/// actions can confirm or refuse (#6500). Cleared by the next key.
notice: Option<(String, bool)>,
notice: Option<(String, StatusToastLevel)>,
/// Theme the notice resolves its semantic ink through.
theme: codewhale_palette::UiTheme,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -649,6 +651,7 @@ impl ModelPickerView {
purpose: ModelPickerPurpose::Session,
assignment_context: None,
notice: None,
theme: app.ui_theme,
};
view.restore_memory(app.model_picker_memory.as_ref());
view
@@ -774,7 +777,8 @@ impl ModelPickerView {
self.sort.is_none() && query.is_empty() && self.view == ModelListView::Catalog;
let mut rows: Vec<_> = visible
.iter()
.map(|row| PaneRow {
.map(|row| (row, pin_for_row(&self.pinned_models, row)))
.map(|(row, pin)| PaneRow {
primary: if self.purpose != ModelPickerPurpose::Session
&& row.id == "auto"
&& row.provider.is_none()
@@ -812,12 +816,20 @@ impl ModelPickerView {
.replace("{date}", &not_listed.checked),
);
}
if let Some(pin) = pin_for_row(&self.pinned_models, row) {
chips.insert(0, pin.label.clone().unwrap_or_else(|| "pinned".into()));
if let Some(pin) = pin {
chips.insert(
0,
pin.label.clone().unwrap_or_else(|| {
tr(self.locale, MessageId::ModelPickerPinnedChip).into_owned()
}),
);
}
chips
},
family: grouped
// Pinned rows sort above every group, so they draw no family
// header: otherwise the family's header repeats where its
// unpinned rows begin.
family: (grouped && pin.is_none())
.then(|| {
row.provider.and_then(|provider| {
catalog_family_for_identity(
@@ -2033,10 +2045,22 @@ pub(crate) fn provider_scoped_model_completion_ids(app: &App) -> Vec<String> {
/// The pin (Fleet model or the person's own) naming this exact row, if any.
fn pin_for_row<'a>(pins: &'a [PinnedModel], row: &ModelPickerRow) -> Option<&'a PinnedModel> {
pins.iter().find(|pin| {
row_provider_identity(row).is_some_and(|provider| provider == pin.provider)
&& row.id == pin.model
})
pins.iter().find(|pin| pin_names_row(pin, row))
}
/// One route match for sorting, marking and labelling alike. Built-in
/// provider slugs are case-insensitive aliases; a named custom provider's
/// identity is its exact config key, so `TeamA` and `teama` stay two routes.
/// Model ids are exact.
fn pin_names_row(pin: &PinnedModel, row: &ModelPickerRow) -> bool {
let named_custom = row.provider == Some(ApiProvider::Custom);
row_provider_identity(row).is_some_and(|provider| {
if named_custom {
provider == pin.provider
} else {
provider.eq_ignore_ascii_case(&pin.provider)
}
}) && row.id == pin.model
}
/// The pins the picker sorts and labels by: the fleet's models first (the
@@ -2145,7 +2169,8 @@ fn picker_model_rows_for_app(app: &App, config: &Config) -> Vec<ModelPickerRow>
let pins = picker_pins_for_app(app);
for row in &mut rows {
if let Some(pin) = pin_for_row(&pins, row) {
let label = pin.label.as_deref().unwrap_or("pinned");
let pinned = tr(app.ui_locale, MessageId::ModelPickerPinnedChip);
let label = pin.label.as_deref().unwrap_or(&pinned);
row.hint = format!(
"{label} · exact {} / {} · {}",
pin.provider, pin.model, row.hint
@@ -3875,6 +3900,7 @@ pub(crate) fn format_picker_context_window(tokens: u64) -> String {
impl ModelPickerView {
/// Rebuild model rows from a fresh app/config snapshot (readiness + catalog).
pub fn re_resolve_from_app(&mut self, app: &App, config: &Config) {
self.theme = app.ui_theme;
let selected = self
.visible_model_rows()
.get(self.selected_model_idx)
@@ -3936,8 +3962,8 @@ impl ModelPickerView {
}
/// Show an action receipt inside the picker (see `notice`).
pub fn set_notice(&mut self, text: String, failed: bool) {
self.notice = Some((text, failed));
pub fn set_notice(&mut self, text: String, level: StatusToastLevel) {
self.notice = Some((text, level));
}
}
@@ -4303,7 +4329,9 @@ impl ModelPickerView {
}
// Keep compact route modals focused on the core browse/apply actions;
// wider shells have room to disclose the pin action too.
if inner.width >= 72 && inner.height >= 16 {
// ⇧P / ⇧F act only on a highlighted catalog route; without one
// (typing a custom id) they are query text, so they are not offered.
if inner.width >= 72 && inner.height >= 16 && self.highlighted_route().is_some() {
if self.purpose == ModelPickerPurpose::Session {
footer_hints.push(ActionHint::new(
"⇧F",
@@ -4357,11 +4385,10 @@ impl ModelPickerView {
.as_ref()
.map(|(text, _)| format!(" · {text}"))
.unwrap_or_default(),
Style::default().fg(if self.notice.as_ref().is_some_and(|(_, failed)| *failed) {
palette::STATUS_WARNING
} else {
palette::TEXT_PRIMARY
}),
self.notice
.as_ref()
.map(|(_, level)| palette::chrome_style(&self.theme, level.ink()))
.unwrap_or_default(),
),
Span::styled(
if self.assignment_context.is_some() {
@@ -4750,6 +4777,29 @@ mod tests {
}
}
/// #6523 review: named custom providers are exact config keys, so a pin
/// on `TeamA` must not mark the same model under `teama`; built-in slugs
/// stay case-insensitive aliases.
#[test]
fn pin_matching_is_exact_for_named_custom_providers_only() {
let pin = |provider: &str| PinnedModel {
provider: provider.to_string(),
model: "model".to_string(),
label: None,
};
let custom = |identity: &str| ModelPickerRow {
provider_identity: Some(identity.to_string()),
..model_row(ApiProvider::Custom, true)
};
assert!(pin_names_row(&pin("TeamA"), &custom("TeamA")));
assert!(!pin_names_row(&pin("TeamA"), &custom("teama")));
assert!(!pin_names_row(&pin("teama"), &custom("TeamA")));
let builtin = model_row(ApiProvider::Deepseek, true);
assert!(pin_names_row(&pin("deepseek"), &builtin));
assert!(pin_names_row(&pin("DeepSeek"), &builtin));
}
#[test]
fn locked_model_keeps_keyboard_focus_visible_without_becoming_selectable() {
let mut picker = test_picker();
@@ -4832,6 +4882,7 @@ mod tests {
purpose: ModelPickerPurpose::Session,
assignment_context: None,
notice: None,
theme: codewhale_palette::UI_THEME,
}
}
+46 -26
View File
@@ -57,7 +57,7 @@ pub(crate) fn refresh_parked_fleet_roster(app: &mut App, config: &Config) {
pub(super) fn refresh_open_model_picker(
app: &mut App,
config: &Config,
notice: Option<(String, bool)>,
notice: Option<(String, StatusToastLevel)>,
) -> bool {
if app.view_stack.top_kind() != Some(ModalKind::ModelPicker) {
return false;
@@ -70,8 +70,8 @@ pub(super) fn refresh_open_model_picker(
.downcast_mut::<crate::tui::model_picker::ModelPickerView>()
{
picker.re_resolve_from_app(app, config);
if let Some((text, failed)) = notice {
picker.set_notice(text, failed);
if let Some((text, level)) = notice {
picker.set_notice(text, level);
}
}
app.view_stack.push_boxed(boxed);
@@ -85,18 +85,29 @@ pub(super) fn toggle_model_picker_pin(
provider_key: &str,
model: &str,
) {
let (receipt, failed) = match crate::settings::Settings::transact(|settings| {
let locale = app.ui_locale;
let route = format!("{provider_key}/{model}");
let (receipt, level) = match crate::settings::Settings::transact(|settings| {
Ok(settings.toggle_pinned_model(provider_key, model))
}) {
Ok(true) => (format!("Pinned {provider_key}/{model}"), false),
Ok(false) => (format!("Unpinned {provider_key}/{model}"), false),
Err(error) => (format!("Could not update pin: {error}"), true),
Ok(true) => (
tr(locale, MessageId::ModelPickerPinned).replace("{route}", &route),
StatusToastLevel::Success,
),
Ok(false) => (
tr(locale, MessageId::ModelPickerUnpinned).replace("{route}", &route),
StatusToastLevel::Success,
),
Err(error) => (
tr(locale, MessageId::ModelPickerPinFailed).replace("{error}", &error.to_string()),
StatusToastLevel::Error,
),
};
if let Ok(settings) = crate::settings::Settings::load_persisted() {
app.pinned_models = settings.pinned_models;
}
app.status_message = Some(receipt.clone());
refresh_open_model_picker(app, config, Some((receipt, failed)));
refresh_open_model_picker(app, config, Some((receipt, level)));
app.needs_redraw = true;
}
@@ -110,11 +121,11 @@ pub(super) fn toggle_model_picker_fleet(
) {
use crate::fleet::members::{FleetModelChange, change_receipt, toggle_fleet_model};
let locale = app.ui_locale;
let (receipt, failed) = if let Some(rejection) =
let (receipt, level) = if let Some(rejection) =
crate::commands::fleet_provider_rejection(app, config, provider_key)
{
app.set_sticky_status(rejection.clone(), StatusToastLevel::Error, None);
(rejection, true)
(rejection, StatusToastLevel::Error)
} else {
match toggle_fleet_model(&app.workspace, provider_key, model) {
Ok(change) => {
@@ -126,17 +137,17 @@ pub(super) fn toggle_model_picker_fleet(
};
let receipt = change_receipt(locale, provider_key, model, &change);
app.push_status_toast(receipt.clone(), level, Some(FLEET_TOGGLE_TOAST_TTL_MS));
(receipt, false)
(receipt, level)
}
Err(error) => {
let message = tr(locale, MessageId::FleetToggleFailed)
.replace("{error}", &error.message(locale));
app.set_sticky_status(message.clone(), StatusToastLevel::Error, None);
(message, true)
(message, StatusToastLevel::Error)
}
}
};
refresh_open_model_picker(app, config, Some((receipt, failed)));
refresh_open_model_picker(app, config, Some((receipt, level)));
app.needs_redraw = true;
}
@@ -2451,15 +2462,14 @@ pub(crate) async fn handle_view_events(
// rebuild catalog rows. Non-destructive: never clears the list
// when a refresh fails; just re-project from current config.
sync_config_provider_from_app(config, app);
let refreshed = "Model readiness refreshed · catalog rows rebuilt";
app.status_message = Some(
if refresh_open_model_picker(app, config, Some((refreshed.into(), false))) {
refreshed
} else {
"Open /model to refresh readiness and catalog"
}
.into(),
);
let refreshed =
tr(app.ui_locale, MessageId::ModelPickerReadinessRefreshed).into_owned();
let notice = Some((refreshed.clone(), StatusToastLevel::Info));
app.status_message = Some(if refresh_open_model_picker(app, config, notice) {
refreshed
} else {
tr(app.ui_locale, MessageId::ModelPickerOpenToRefresh).into_owned()
});
app.needs_redraw = true;
}
ViewEvent::ModelPickerToggleFleet {
@@ -2495,14 +2505,24 @@ pub(crate) async fn handle_view_events(
Ok(None) => {}
Ok(Some(pinned_models)) => {
app.pinned_models = pinned_models;
let receipt = "Pinned model order updated".to_string();
let receipt =
tr(app.ui_locale, MessageId::ModelPickerPinOrderUpdated).into_owned();
app.status_message = Some(receipt.clone());
refresh_open_model_picker(app, config, Some((receipt, false)));
refresh_open_model_picker(
app,
config,
Some((receipt, StatusToastLevel::Success)),
);
}
Err(error) => {
let receipt = format!("Could not reorder pin: {error}");
let receipt = tr(app.ui_locale, MessageId::ModelPickerPinReorderFailed)
.replace("{error}", &error.to_string());
app.status_message = Some(receipt.clone());
refresh_open_model_picker(app, config, Some((receipt, true)));
refresh_open_model_picker(
app,
config,
Some((receipt, StatusToastLevel::Error)),
);
}
}
app.needs_redraw = true;
@@ -211,3 +211,85 @@ fn shift_d_while_searching_is_query_text_not_a_provider_auth_detour() {
assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ModelPicker));
assert!(render_picker(&mut app).contains(&format!("Model: {model}D")));
}
/// #6523 review: a failed mutation renders in the theme's failure slot, not
/// warning ink, and success in the outcome slot — the typed toast level is
/// carried into the picker's receipt.
#[test]
fn picker_receipt_renders_its_semantic_level_through_the_palette() {
let _lock = crate::test_support::lock_test_env();
let root = tempfile::tempdir().unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
let (mut app, config) = app_with_open_picker(root.path());
let theme = app.ui_theme;
let receipt_fg = |app: &mut App, text: &str| {
let view = app.view_stack.pop().unwrap();
let area = Rect::new(0, 0, 140, 40);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
app.view_stack.push_boxed(view);
let first = text.chars().next().unwrap().to_string();
(0..area.height)
.find_map(|y| {
let line: String = (0..area.width).map(|x| buf[(x, y)].symbol()).collect();
let col = line.find(text)?;
let x = line[..col].chars().count() as u16;
assert_eq!(buf[(x, y)].symbol(), first);
Some(buf[(x, y)].fg)
})
.unwrap_or_else(|| panic!("receipt {text:?} not rendered"))
};
let failure = "reorder refused";
super::super::handlers::refresh_open_model_picker(
&mut app,
&config,
Some((
failure.to_string(),
crate::tui::app::StatusToastLevel::Error,
)),
);
assert_eq!(receipt_fg(&mut app, failure), theme.error_fg);
let success = "order saved";
super::super::handlers::refresh_open_model_picker(
&mut app,
&config,
Some((
success.to_string(),
crate::tui::app::StatusToastLevel::Success,
)),
);
assert_eq!(receipt_fg(&mut app, success), theme.status_working);
}
/// #6523 review: while a custom model id is typed there is no highlighted
/// catalog route, ⇧P / ⇧F are query text, so the footer must not offer them.
#[test]
fn pin_and_fleet_hints_hide_when_no_catalog_route_is_highlighted() {
let _lock = crate::test_support::lock_test_env();
let root = tempfile::tempdir().unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
let (mut app, _config) = app_with_open_picker(root.path());
let model = app.model.clone();
type_query(&mut app, &model);
let screen = render_picker(&mut app);
assert!(
screen.contains("⇧P") && screen.contains("⇧F"),
"a highlighted route offers its verbs:\n{screen}"
);
let mut app_custom = {
let (app, _config) = app_with_open_picker(root.path());
app
};
type_query(&mut app_custom, "zz-no-such-model-xyz");
let screen = render_picker(&mut app_custom);
assert!(
!screen.contains("⇧P") && !screen.contains("⇧F"),
"no highlighted route, no Pin/Fleet hints:\n{screen}"
);
let events = app_custom.view_stack.handle_key(shifted('P'));
assert!(events.is_empty(), "{events:?}");
}