mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
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:
1 parent
a46aff0ee0
commit
f04f8a5916
19 files changed
+350
-61
No files matched your search
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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é"
|
||||
}
|
||||
@@ -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": "पिन किया"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "ピン留め"
|
||||
}
|
||||
@@ -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": "고정됨"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "закреплено"
|
||||
}
|
||||
@@ -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": "закріплено"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "已固定"
|
||||
}
|
||||
@@ -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": "已釘選"
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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}", ¬_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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
Reference in new issue
Block a user