Merge pull request #6523 from Hmbown/fix/model-picker-actions

fix(tui): model picker pin and Fleet work while searching and show their receipt
This commit is contained in:
Hunter Bown authored and GitHub committed 2026-09-24 21:53:26 -07:00
commit 4475c6d3dc
21 files changed
+730 -156

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> {
+140 -40
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,6 +266,12 @@ pub struct ModelPickerView {
catalog_action_hovered: bool,
purpose: ModelPickerPurpose,
assignment_context: Option<(String, String)>,
/// 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, StatusToastLevel)>,
/// Theme the notice resolves its semantic ink through.
theme: codewhale_palette::UiTheme,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -644,6 +650,8 @@ impl ModelPickerView {
catalog_action_hovered: false,
purpose: ModelPickerPurpose::Session,
assignment_context: None,
notice: None,
theme: app.ui_theme,
};
view.restore_memory(app.model_picker_memory.as_ref());
view
@@ -769,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()
@@ -794,6 +803,10 @@ impl ModelPickerView {
meta: if row.provider.is_none() {
vec![row.hint.clone()]
} else {
// A pin or Fleet membership leads the chips so a squeezed
// row never sheds it: without it ⇧P / ⇧F changed nothing
// the person could see (#6500). A not-listed notice still
// leads when there is no pin.
let mut chips = model_row_meta_chips(row);
if let Some(not_listed) = &row.not_listed {
chips.insert(
@@ -803,9 +816,20 @@ impl ModelPickerView {
.replace("{date}", &not_listed.checked),
);
}
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(
@@ -2019,6 +2043,26 @@ pub(crate) fn provider_scoped_model_completion_ids(app: &App) -> Vec<String> {
provider_scoped_model_ids_for_app(app, true)
}
/// 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| 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
/// selected Fleet's operator and every pinned member, labelled with the roles
/// each fills — design §10 F1), then the person's own pins.
@@ -2124,11 +2168,9 @@ fn picker_model_rows_for_app(app: &App, config: &Config) -> Vec<ModelPickerRow>
// chose rather than with a provider's alphabet.
let pins = picker_pins_for_app(app);
for row in &mut rows {
if let Some(pin) = pins.iter().find(|pin| {
row_provider_identity(row).is_some_and(|provider| provider == pin.provider)
&& row.id == pin.model
}) {
let label = pin.label.as_deref().unwrap_or("pinned");
if let Some(pin) = pin_for_row(&pins, row) {
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
@@ -3858,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)
@@ -3898,21 +3941,30 @@ impl ModelPickerView {
}
impl ModelPickerView {
fn emit_pin_move(&self, delta: isize) -> ViewAction {
/// Exact route of the highlighted catalog row: the target of ⇧P, ⇧F and
/// Alt+↑↓. `None` on provider-less rows (`auto`) and the custom row.
fn highlighted_route(&self) -> Option<(ApiProvider, Option<String>, String)> {
let rows = self.visible_model_rows();
let Some(row) = rows.get(self.selected_model_idx) else {
return ViewAction::None;
};
let Some(provider) = row.provider else {
let row = rows.get(self.selected_model_idx)?;
Some((row.provider?, row.provider_identity.clone(), row.id.clone()))
}
fn emit_pin_move(&self, delta: isize) -> ViewAction {
let Some((provider, provider_id, model)) = self.highlighted_route() else {
return ViewAction::None;
};
ViewAction::Emit(ViewEvent::ModelPickerMovePin {
provider,
provider_id: row.provider_identity.clone(),
model: row.id.clone(),
provider_id,
model,
delta,
})
}
/// Show an action receipt inside the picker (see `notice`).
pub fn set_notice(&mut self, text: String, level: StatusToastLevel) {
self.notice = Some((text, level));
}
}
impl ModalView for ModelPickerView {
@@ -3927,6 +3979,7 @@ impl ModalView for ModelPickerView {
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
self.last_mouse_selected = None;
self.hovered_row = None;
self.notice = None;
// Movement keys come from the shared vocabulary (#6290); the match
// below owns only the picker's own verbs. The live filter means the
// typing-safe set — no letter aliases to eat the query.
@@ -3980,44 +4033,49 @@ impl ModalView for ModelPickerView {
{
ViewAction::EmitAndClose(self.build_apply_event(true))
}
// Only an empty query explains a locked row. While searching,
// ⇧D is query text: it used to open provider auth for a row that
// was not locked at all (#6500).
KeyCode::Char(ch)
if key.modifiers.contains(KeyModifiers::SHIFT) && ch.eq_ignore_ascii_case(&'d') =>
if key.modifiers.contains(KeyModifiers::SHIFT)
&& self.query.is_empty()
&& ch.eq_ignore_ascii_case(&'d') =>
{
self.explain_unselectable_selection()
}
// Pinning must never steal the first character of a route search:
// use the explicitly shifted key advertised in the footer.
KeyCode::Char('P') if key.modifiers == KeyModifiers::SHIFT && self.query.is_empty() => {
let rows = self.visible_model_rows();
let Some(row) = rows.get(self.selected_model_idx) else {
return ViewAction::None;
};
let Some(provider) = row.provider else {
// ⇧P / ⇧F act on the highlighted row, searching or not: a search
// is how a model is found, and the verbs used to become query
// text the moment one was typed (#6500). The filter ignores case,
// so only a custom id being typed (no catalog row highlighted)
// still receives the capital letter. Some terminals report the
// chord as lowercase + SHIFT, hence `eq_ignore_ascii_case`.
KeyCode::Char(ch)
if key.modifiers == KeyModifiers::SHIFT
&& ch.eq_ignore_ascii_case(&'p')
&& (self.query.is_empty() || self.highlighted_route().is_some()) =>
{
let Some((provider, provider_id, model)) = self.highlighted_route() else {
return ViewAction::None;
};
ViewAction::Emit(ViewEvent::ModelPickerTogglePin {
provider,
provider_id: row.provider_identity.clone(),
model: row.id.clone(),
provider_id,
model,
})
}
// Same rule as pinning: a shifted key, never a search character.
KeyCode::Char('F')
KeyCode::Char(ch)
if key.modifiers == KeyModifiers::SHIFT
&& self.query.is_empty()
&& self.purpose == ModelPickerPurpose::Session =>
&& ch.eq_ignore_ascii_case(&'f')
&& self.purpose == ModelPickerPurpose::Session
&& (self.query.is_empty() || self.highlighted_route().is_some()) =>
{
let rows = self.visible_model_rows();
let Some(row) = rows.get(self.selected_model_idx) else {
return ViewAction::None;
};
let Some(provider) = row.provider else {
let Some((provider, provider_id, model)) = self.highlighted_route() else {
return ViewAction::None;
};
ViewAction::Emit(ViewEvent::ModelPickerToggleFleet {
provider,
provider_id: row.provider_identity.clone(),
model: row.id.clone(),
provider_id,
model,
})
}
KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) && self.query.is_empty() => {
@@ -4234,6 +4292,9 @@ impl ModelPickerView {
})
.render(action, buf);
}
// ⇧A and ⇧D are query text while a search is typed; advertise them
// only when they act.
let searching = !self.query.is_empty();
let mut footer_hints = vec![
ActionHint::new("↑↓", tr(self.locale, MessageId::PickerActionMove)),
ActionHint::new("Tab", tr(self.locale, MessageId::PickerActionSwitch)),
@@ -4242,8 +4303,10 @@ impl ModelPickerView {
tr(self.locale, MessageId::RouteActionSearchAnyModel),
),
ActionHint::new("Enter", tr(self.locale, self.apply_action_id())),
ActionHint::new("⇧A", view_action),
];
if !searching {
footer_hints.push(ActionHint::new("⇧A", view_action));
}
if inner.height >= 16 {
footer_hints.push(ActionHint::new(
"Ctrl+S",
@@ -4255,7 +4318,7 @@ impl ModelPickerView {
}
// A Fleet row has no startup default to save; the chord is a
// session-route action only.
if self.purpose == ModelPickerPurpose::Session && inner.height >= 16 {
if self.purpose == ModelPickerPurpose::Session && inner.height >= 16 && !searching {
footer_hints.insert(
4,
ActionHint::new(
@@ -4266,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",
@@ -4315,6 +4380,16 @@ impl ModelPickerView {
.unwrap_or_default(),
Style::default().fg(palette::STATUS_WARNING),
),
Span::styled(
self.notice
.as_ref()
.map(|(text, _)| format!(" · {text}"))
.unwrap_or_default(),
self.notice
.as_ref()
.map(|(_, level)| palette::chrome_style(&self.theme, level.ink()))
.unwrap_or_default(),
),
Span::styled(
if self.assignment_context.is_some() {
""
@@ -4702,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();
@@ -4783,6 +4881,8 @@ mod tests {
catalog_action_hovered: false,
purpose: ModelPickerPurpose::Session,
assignment_context: None,
notice: None,
theme: codewhale_palette::UI_THEME,
}
}
+126 -92
View File
@@ -50,6 +50,107 @@ pub(crate) fn refresh_parked_fleet_roster(app: &mut App, config: &Config) {
app.view_stack.push_boxed(view);
}
/// Rebuild the open model picker from live state and show `notice` inside
/// it. The picker covers the status line, so a receipt written only there
/// made ⇧P and ⇧F look like they did nothing (#6500). Returns whether a
/// picker was open.
pub(super) fn refresh_open_model_picker(
app: &mut App,
config: &Config,
notice: Option<(String, StatusToastLevel)>,
) -> bool {
if app.view_stack.top_kind() != Some(ModalKind::ModelPicker) {
return false;
}
let Some(mut boxed) = app.view_stack.pop() else {
return false;
};
if let Some(picker) = boxed
.as_any_mut()
.downcast_mut::<crate::tui::model_picker::ModelPickerView>()
{
picker.re_resolve_from_app(app, config);
if let Some((text, level)) = notice {
picker.set_notice(text, level);
}
}
app.view_stack.push_boxed(boxed);
true
}
/// The picker's ⇧P: toggle the exact route in `settings.toml`'s pins.
pub(super) fn toggle_model_picker_pin(
app: &mut App,
config: &Config,
provider_key: &str,
model: &str,
) {
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) => (
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, level)));
app.needs_redraw = true;
}
/// The picker's ⇧F: add the exact route to the selected Fleet, or remove the
/// shortlist row it added. Same provider gate as `/fleet add`.
pub(super) fn toggle_model_picker_fleet(
app: &mut App,
config: &Config,
provider_key: &str,
model: &str,
) {
use crate::fleet::members::{FleetModelChange, change_receipt, toggle_fleet_model};
let locale = app.ui_locale;
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, StatusToastLevel::Error)
} else {
match toggle_fleet_model(&app.workspace, provider_key, model) {
Ok(change) => {
let level = if matches!(change, FleetModelChange::Unchanged { .. }) {
StatusToastLevel::Info
} else {
app.fleet_roster_stale = true;
StatusToastLevel::Success
};
let receipt = change_receipt(locale, provider_key, model, &change);
app.push_status_toast(receipt.clone(), level, Some(FLEET_TOGGLE_TOAST_TTL_MS));
(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, StatusToastLevel::Error)
}
}
};
refresh_open_model_picker(app, config, Some((receipt, level)));
app.needs_redraw = true;
}
pub(super) fn dismiss_fleet_assignment(app: &mut App, editor_id: uuid::Uuid) {
if let Some(mut boxed) = app.view_stack.pop() {
let remove = if let Some(view) = boxed
@@ -2361,22 +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);
if app.view_stack.top_kind() == Some(ModalKind::ModelPicker)
&& let Some(mut boxed) = app.view_stack.pop()
{
if let Some(picker) = boxed
.as_any_mut()
.downcast_mut::<crate::tui::model_picker::ModelPickerView>(
) {
picker.re_resolve_from_app(app, config);
app.status_message =
Some("Model readiness refreshed · catalog rows rebuilt".into());
}
app.view_stack.push_boxed(boxed);
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 {
app.status_message =
Some("Open /model to refresh readiness and catalog".into());
}
tr(app.ui_locale, MessageId::ModelPickerOpenToRefresh).into_owned()
});
app.needs_redraw = true;
}
ViewEvent::ModelPickerToggleFleet {
@@ -2384,49 +2477,8 @@ pub(crate) async fn handle_view_events(
provider_id,
model,
} => {
use crate::fleet::members::{FleetModelChange, change_receipt, toggle_fleet_model};
let provider_key = provider_id.unwrap_or_else(|| provider.as_str().to_string());
let locale = app.ui_locale;
// Same gate as `/fleet add`, against the live config: a
// locked or unauthenticated provider row never enters the
// fleet from the picker either.
if let Some(rejection) =
crate::commands::fleet_provider_rejection(app, config, &provider_key)
{
app.set_sticky_status(rejection, StatusToastLevel::Error, None);
} else {
match toggle_fleet_model(&app.workspace, &provider_key, &model) {
Ok(change) => {
let level = if matches!(change, FleetModelChange::Unchanged { .. }) {
StatusToastLevel::Info
} else {
app.fleet_roster_stale = true;
StatusToastLevel::Success
};
app.push_status_toast(
change_receipt(locale, &provider_key, &model, &change),
level,
Some(FLEET_TOGGLE_TOAST_TTL_MS),
);
}
Err(error) => app.set_sticky_status(
tr(locale, MessageId::FleetToggleFailed)
.replace("{error}", &error.message(locale)),
StatusToastLevel::Error,
None,
),
}
}
if let Some(mut boxed) = app.view_stack.pop() {
if let Some(picker) = boxed
.as_any_mut()
.downcast_mut::<crate::tui::model_picker::ModelPickerView>(
) {
picker.re_resolve_from_app(app, config);
}
app.view_stack.push_boxed(boxed);
}
app.needs_redraw = true;
toggle_model_picker_fleet(app, config, &provider_key, &model);
}
ViewEvent::ModelPickerTogglePin {
provider,
@@ -2434,30 +2486,7 @@ pub(crate) async fn handle_view_events(
model,
} => {
let provider_key = provider_id.unwrap_or_else(|| provider.as_str().to_string());
match crate::settings::Settings::transact(|settings| {
Ok(settings.toggle_pinned_model(&provider_key, &model))
}) {
Ok(true) => app.status_message = Some(format!("Pinned {provider_key}/{model}")),
Ok(false) => {
app.status_message = Some(format!("Unpinned {provider_key}/{model}"))
}
Err(error) => {
app.status_message = Some(format!("Could not update pin: {error}"))
}
}
if let Ok(settings) = crate::settings::Settings::load_persisted() {
app.pinned_models = settings.pinned_models;
}
if let Some(mut boxed) = app.view_stack.pop() {
if let Some(picker) = boxed
.as_any_mut()
.downcast_mut::<crate::tui::model_picker::ModelPickerView>(
) {
picker.re_resolve_from_app(app, config);
}
app.view_stack.push_boxed(boxed);
}
app.needs_redraw = true;
toggle_model_picker_pin(app, config, &provider_key, &model);
}
ViewEvent::ModelPickerMovePin {
provider,
@@ -2476,19 +2505,24 @@ pub(crate) async fn handle_view_events(
Ok(None) => {}
Ok(Some(pinned_models)) => {
app.pinned_models = pinned_models;
app.status_message = Some("Pinned model order updated".into());
if let Some(mut boxed) = app.view_stack.pop() {
if let Some(picker) = boxed
.as_any_mut()
.downcast_mut::<crate::tui::model_picker::ModelPickerView>(
) {
picker.re_resolve_from_app(app, config);
}
app.view_stack.push_boxed(boxed);
}
let receipt =
tr(app.ui_locale, MessageId::ModelPickerPinOrderUpdated).into_owned();
app.status_message = Some(receipt.clone());
refresh_open_model_picker(
app,
config,
Some((receipt, StatusToastLevel::Success)),
);
}
Err(error) => {
app.status_message = Some(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, StatusToastLevel::Error)),
);
}
}
app.needs_redraw = true;
+1
View File
@@ -57,6 +57,7 @@ use crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};
use codewhale_models::Role;
use tempfile::TempDir;
mod model_picker_actions;
mod runtime_store_binding;
#[test]
@@ -0,0 +1,295 @@
//! #6500: the model picker's ⇧P / ⇧F / ⇧D after a search, driven through the
//! open view stack, the event handler, disk, and the rendered picker.
use super::*;
use crate::tui::model_picker::ModelPickerView;
use crate::tui::ui::handlers::{toggle_model_picker_fleet, toggle_model_picker_pin};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{buffer::Buffer, layout::Rect};
/// An app on a keyed DeepSeek route with `/model` open, under a temp home.
#[cfg(test)]
fn app_with_open_picker(root: &std::path::Path) -> (App, Config) {
let workspace = root.join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
let mut config = Config::default();
config.set_provider_api_key_override(ApiProvider::Deepseek, Some("fixture-key".into()));
let mut app = App::new(crate::test_support::test_tui_options(&workspace), &config);
app.workspace = workspace;
let picker = ModelPickerView::new(&app, &config);
app.view_stack.push(picker);
(app, config)
}
fn type_query(app: &mut App, query: &str) {
for ch in query.chars() {
let events = app
.view_stack
.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
assert!(events.is_empty(), "typing {ch:?} emitted {events:?}");
}
}
fn shifted(ch: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::SHIFT)
}
fn render_picker(app: &mut App) -> String {
assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ModelPicker));
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);
(0..area.height)
.map(|y| {
(0..area.width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
/// The picker line that lists `model` as a row (not the query title or the
/// receipt line).
fn row_line<'a>(screen: &'a str, model: &str) -> &'a str {
screen
.lines()
.find(|line| {
line.contains(model) && !line.contains("Model:") && !line.contains(&format!("/{model}"))
})
.unwrap_or_else(|| panic!("no row for {model}:\n{screen}"))
}
#[test]
fn shift_p_after_a_search_pins_the_highlighted_row_and_says_so_in_the_picker() {
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 events = app.view_stack.handle_key(shifted('P'));
let [
ViewEvent::ModelPickerTogglePin {
provider,
provider_id,
model: picked,
},
] = events.as_slice()
else {
panic!("⇧P while searching must pin the highlighted row, got {events:?}");
};
assert_eq!(picked, &model);
let provider_key = provider_id
.clone()
.unwrap_or_else(|| provider.as_str().to_string());
toggle_model_picker_pin(&mut app, &config, &provider_key, picked);
let saved = crate::settings::Settings::load_persisted().unwrap();
assert!(
saved
.pinned_models
.iter()
.any(|pin| pin.provider == provider_key && pin.model == model),
"pin not persisted: {:?}",
saved.pinned_models
);
let screen = render_picker(&mut app);
assert!(
screen.contains(&format!("Pinned {provider_key}/{model}")),
"receipt must be visible inside the picker:\n{screen}"
);
assert!(
row_line(&screen, &model).contains("pinned"),
"row must carry its pin:\n{screen}"
);
assert!(
screen.contains(&format!("Model: {model}")) && !screen.contains(&format!("{model}P")),
"⇧P must not become query text:\n{screen}"
);
// The same chord on the same row unpins it, on disk and on screen.
let events = app.view_stack.handle_key(shifted('P'));
assert!(matches!(
events.as_slice(),
[ViewEvent::ModelPickerTogglePin { model: again, .. }] if again == &model
));
toggle_model_picker_pin(&mut app, &config, &provider_key, &model);
let saved = crate::settings::Settings::load_persisted().unwrap();
assert!(saved.pinned_models.is_empty(), "{:?}", saved.pinned_models);
let screen = render_picker(&mut app);
assert!(
screen.contains(&format!("Unpinned {provider_key}/{model}")),
"{screen}"
);
assert!(!row_line(&screen, &model).contains("pinned"), "{screen}");
}
#[test]
fn shift_f_after_a_search_adds_the_highlighted_row_to_fleet_and_says_so_in_the_picker() {
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 events = app.view_stack.handle_key(shifted('F'));
let [
ViewEvent::ModelPickerToggleFleet {
provider,
provider_id,
model: picked,
},
] = events.as_slice()
else {
panic!("⇧F while searching must toggle Fleet for the highlighted row, got {events:?}");
};
assert_eq!(picked, &model);
let provider_key = provider_id
.clone()
.unwrap_or_else(|| provider.as_str().to_string());
toggle_model_picker_fleet(&mut app, &config, &provider_key, picked);
let fleet = crate::fleet::members::fleet_models(&app.workspace).unwrap();
assert!(
fleet
.iter()
.any(|member| member.matches(&provider_key, &model)),
"route not in the selected fleet: {fleet:?}"
);
assert!(app.fleet_roster_stale, "the engine roster must be resynced");
let screen = render_picker(&mut app);
assert!(
screen.contains(&format!("{provider_key}/{model}")),
"receipt must be visible inside the picker:\n{screen}"
);
assert!(
row_line(&screen, &model).contains("fleet · "),
"row must carry its Fleet membership:\n{screen}"
);
// Toggling again removes the shortlist row it added.
let events = app.view_stack.handle_key(shifted('F'));
assert!(matches!(
events.as_slice(),
[ViewEvent::ModelPickerToggleFleet { model: again, .. }] if again == &model
));
toggle_model_picker_fleet(&mut app, &config, &provider_key, &model);
let fleet = crate::fleet::members::fleet_models(&app.workspace).unwrap();
assert!(
!fleet
.iter()
.any(|member| member.matches(&provider_key, &model)),
"{fleet:?}"
);
let screen = render_picker(&mut app);
assert!(!row_line(&screen, &model).contains("fleet · "), "{screen}");
}
#[test]
fn shift_d_while_searching_is_query_text_not_a_provider_auth_detour() {
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);
assert!(
!render_picker(&mut app).contains("⇧D"),
"⇧D is not advertised while it would be query text"
);
let events = app.view_stack.handle_key(shifted('D'));
assert!(
events.is_empty(),
"⇧D mid-search used to emit a lock explanation / auth hand-off: {events:?}"
);
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:?}");
}
+17 -9
View File
@@ -1,7 +1,7 @@
// Generated from docs/INSTALL.md by scripts/derive-install.mjs. Do not edit.
export const INSTALL_GUIDE = {
"sourceHash": "15d0f9dc73e2f7942318fcbe11ae820ec67118b9d8f1009c6ccc5caf49a98690",
"sourceHash": "36955c1448b6c6d31046183e696705acc4e16f72530db8305381a968c6c4767b",
"anchors": [
"installing-codewhale",
"60-second-quickstart-linux-or-macos",
@@ -86,7 +86,7 @@ export const INSTALL_GUIDE = {
"chunks": [
{
"kind": "html",
"text": "<h1 id=\"installing-codewhale\">Installing Codewhale</h1>\n<blockquote>\n<p>阅读简体中文版:<a href=\"https://github.com/Hmbown/CodeWhale/blob/main/docs/zh_hans/INSTALL.md\">zh_hans/INSTALL.md</a> (not yet updated for this revision)</p>\n</blockquote>\n<p>Codewhale is an open-source coding agent that runs in your terminal. You give\nit a task (&quot;fix the failing test&quot;, &quot;add a CLI flag&quot;). It reads your repository,\nedits files and runs commands, asking your permission first by default. It\nworks with many model providers. <strong>DeepSeek</strong> is the default.</p>\n<p>The command is <code>codewhale</code>. <code>codew</code> is a shorter alias for the same program.</p>\n<p>This guide was written by installing <strong>v0.10.0</strong> (released 2026-09-22) on a\nfresh <strong>Ubuntu 24.04 x86_64</strong> machine, on every path described here. Every\ncommand shown was run and its output checked (see the <a href=\"https://github.com/Hmbown/Codewhale/blob/37ecdfcc49bc68a9b0d058b97c3946e62c34bd31/docs/install-report/v0.10.0-2026-09-23/RECEIPTS.md\">install receipts</a>). Steps that\ncould not be run on that machine are marked <strong>(untested on this VM: reason)</strong>.\nmacOS, Windows and Android are out of scope, apart from a few notes. A second\npass re-ran the installer, manual-download, archive and npm paths, the no-key\nchecks and zsh completion on <strong>macOS 26.1 (Apple silicon)</strong>; see\n<a href=\"#macos-notes\">macOS notes</a>. Steps that need a model call were not re-run\nthere.</p>\n<p>Install commands that use <code>latest</code> resolve to the latest <strong>published</strong> GitHub\nRelease or package. Between releases, <code>main</code> may already describe the next\nversion (for example the v0.10.0 source candidate before 2026-09-22). A\ncandidate isn&#39;t installable until its tag, checksums and release assets\nexist.</p>\n<hr>\n<h2 id=\"60-second-quickstart-linux-or-macos\">60-second quickstart (Linux or macOS)</h2>\n"
"text": "<h1 id=\"installing-codewhale\">Installing Codewhale</h1>\n<blockquote>\n<p>阅读简体中文版:<a href=\"https://github.com/Hmbown/CodeWhale/blob/main/docs/zh_hans/INSTALL.md\">zh_hans/INSTALL.md</a> (not yet updated for this revision)</p>\n</blockquote>\n<p>Codewhale is an open-source coding agent that runs in your terminal. You give\nit a task (&quot;fix the failing test&quot;, &quot;add a CLI flag&quot;). It reads your repository,\nedits files and runs commands. In the default <strong>Ask</strong> posture it applies file\nedits inside the workspace immediately (and shows you the diff), but asks before\nrunning shell commands, so commit or stash anything you care about first. It\nworks with many model providers. <strong>DeepSeek</strong> is the default.</p>\n<p>The command is <code>codewhale</code>. <code>codew</code> is a shorter alias for the same program.</p>\n<p>This guide was written by installing <strong>v0.10.0</strong> (released 2026-09-22) on a\nfresh <strong>Ubuntu 24.04 x86_64</strong> machine, on every path described here. Every\ncommand shown was run and its output checked (see the <a href=\"https://github.com/Hmbown/Codewhale/blob/37ecdfcc49bc68a9b0d058b97c3946e62c34bd31/docs/install-report/v0.10.0-2026-09-23/RECEIPTS.md\">install receipts</a>). Steps that\ncould not be run on that machine are marked <strong>(untested on this VM: reason)</strong>.\nmacOS, Windows and Android are out of scope, apart from a few notes. A second\npass re-ran the installer, manual-download, archive and npm paths, the no-key\nchecks and zsh completion on <strong>macOS 26.1 (Apple silicon)</strong>; see\n<a href=\"#macos-notes\">macOS notes</a>. Steps that need a model call were not re-run\nthere.</p>\n<p>Install commands that use <code>latest</code> resolve to the latest <strong>published</strong> GitHub\nRelease or package. Between releases, <code>main</code> may already describe the next\nversion (for example the v0.10.0 source candidate before 2026-09-22). A\ncandidate isn&#39;t installable until its tag, checksums and release assets\nexist.</p>\n<hr>\n<h2 id=\"60-second-quickstart-linux-or-macos\">60-second quickstart (Linux or macOS)</h2>\n"
},
{
"kind": "code",
@@ -118,7 +118,7 @@ export const INSTALL_GUIDE = {
},
{
"kind": "html",
"text": "<h4 id=\"macos-notes\">macOS notes</h4>\n<p>Re-checked on macOS 26.1, Apple silicon (<code>macos-arm64</code>), with a fresh <code>HOME</code>:</p>\n<ul>\n<li>The installer printed <code>Installing Codewhale for macos-arm64</code>, verified\nchecksums with the system tools, and installed <code>codewhale</code> and <code>codew</code>\n(64 MiB each, Mach-O arm64) in 4.3 s. Both report\n<code>codewhale 0.10.0 (1be1a703b975)</code>. They ran without a Gatekeeper prompt.</li>\n<li>When Node isn&#39;t on <code>PATH</code>, it also prints <code>Computer Use is included and needs Node.js 20 or newer on PATH.</code> Everything else works without Node.</li>\n<li><code>codewhale doctor</code> behaves as on Linux (exit 0, <code>All checks complete!</code> with no\nkey, file-based secret store under <code>~/.codewhale/secrets/</code>), except that it\nreports <code>✓ sandbox available: macos-seatbelt</code>.</li>\n</ul>\n<h3 id=\"put-it-on-your-path\">Put it on your PATH</h3>\n<p>If the last lines say <code>PATH selects no codewhale command</code>, <code>~/.local/bin</code> isn&#39;t\non your PATH <strong>in this shell</strong>. On Ubuntu and Debian, <code>~/.profile</code> adds\n<code>~/.local/bin</code>, but only if the directory existed when you <em>logged in</em>. So:</p>\n<ul>\n<li>a new SSH or login shell picks it up automatically;</li>\n<li>a new terminal <strong>window</strong> on a desktop (GNOME Terminal, Ghostty, …) usually\ndoesn&#39;t, until you log out and back in. I hit\n<code>bash: codewhale: command not found</code> in Ghostty right after installing.</li>\n</ul>\n<p>Fix it once:</p>\n"
"text": "<h4 id=\"macos-notes\">macOS notes</h4>\n<p>Re-checked on macOS 26.1, Apple silicon (<code>macos-arm64</code>), with a fresh <code>HOME</code>:</p>\n<ul>\n<li>The installer printed <code>Installing Codewhale for macos-arm64</code>, verified\nchecksums with the system tools, and installed <code>codewhale</code> and <code>codew</code>\n(64 MiB each, Mach-O arm64) in 4.3 s. Both report\n<code>codewhale 0.10.0 (1be1a703b975)</code>. They ran without a Gatekeeper prompt.</li>\n<li>When Node isn&#39;t on <code>PATH</code>, it also prints <code>Computer Use is included and needs Node.js 20 or newer on PATH.</code> The core TUI works without Node, but Computer Use\nand the JavaScript execution tool (<code>js_execution</code>) stay unavailable until Node\nis on <code>PATH</code>.</li>\n<li><code>codewhale doctor</code> behaves as on Linux (exit 0, <code>All checks complete!</code> with no\nkey, file-based secret store under <code>~/.codewhale/secrets/</code>), except that it\nreports <code>✓ sandbox available: macos-seatbelt</code>.</li>\n</ul>\n<h3 id=\"put-it-on-your-path\">Put it on your PATH</h3>\n<p>If the last lines say <code>PATH selects no codewhale command</code>, <code>~/.local/bin</code> isn&#39;t\non your PATH <strong>in this shell</strong>. On Ubuntu and Debian, <code>~/.profile</code> adds\n<code>~/.local/bin</code>, but only if the directory existed when you <em>logged in</em>. So:</p>\n<ul>\n<li>a new SSH or login shell picks it up automatically;</li>\n<li>a new terminal <strong>window</strong> on a desktop (GNOME Terminal, Ghostty, …) usually\ndoesn&#39;t, until you log out and back in. I hit\n<code>bash: codewhale: command not found</code> in Ghostty right after installing.</li>\n</ul>\n<p>Fix it once:</p>\n"
},
{
"kind": "code",
@@ -242,7 +242,7 @@ export const INSTALL_GUIDE = {
},
{
"kind": "code",
"text": "nix run github:Hmbown/CodeWhale -- --version"
"text": "# flakes are still experimental; the tested setup enabled them once:\nmkdir -p ~/.config/nix\necho 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf\nnix run github:Hmbown/CodeWhale -- --version\n# one-off alternative (untested on this VM): nix --extra-experimental-features 'nix-command flakes' run github:Hmbown/CodeWhale -- --version"
},
{
"kind": "html",
@@ -258,11 +258,11 @@ export const INSTALL_GUIDE = {
},
{
"kind": "code",
"text": "rm ~/.local/bin/codewhale ~/.local/bin/codew\ncurl -fsSL https://codewhale.net/install.sh | CODEWHALE_VERSION=v0.9.13 sh\ncodewhale --version # codewhale 0.9.13 (a0b81f619b66)"
"text": "dir=\"$(dirname \"$(command -v codewhale)\")\" # the install PATH actually selects\nrm \"$dir/codewhale\" \"$dir/codew\"\ncurl -fsSL https://codewhale.net/install.sh | CODEWHALE_VERSION=v0.9.13 CODEWHALE_INSTALL_DIR=\"$dir\" sh\nhash -r; codewhale --version # codewhale 0.9.13 (a0b81f619b66)"
},
{
"kind": "html",
"text": "<p>Or keep both versions side by side, and put the old one first on PATH:</p>\n"
"text": "<p>Tested with both the default <code>~/.local/bin</code> and a custom\n<code>CODEWHALE_INSTALL_DIR</code>. Use it only for installer, manual or archive\ninstalls. Never point it at an npm, Cargo or Homebrew directory.</p>\n<p>Or keep both versions side by side, and put the old one first on PATH:</p>\n"
},
{
"kind": "code",
@@ -326,7 +326,15 @@ export const INSTALL_GUIDE = {
},
{
"kind": "html",
"text": "<ul>\n<li>The composer is at the bottom. The footer shows the permission posture\n(<code>ask</code>), the mode (<code>work</code>) and the model (<code>DeepSeek · deepseek-flash</code>).</li>\n<li><strong>Shift+Tab</strong> cycles the permission posture: Ask → Auto-Review → Full Access.\n<strong>Tab</strong> (with an empty composer) cycles the mode: Plan → Work → Operate.</li>\n<li>In <strong>Ask</strong>, file edits in the workspace are applied and shown as a diff.\nShell commands stop at an <strong>APPROVAL</strong> prompt: <code>y</code> allow once, <code>a</code> allow for\nthis session, <code>n</code> deny, <code>Esc</code> abort the turn.</li>\n<li>Useful keys: <strong>F1</strong> help (or <code>/help</code>), <strong>Ctrl-K</strong> command palette, <strong>F3</strong>\nprovider/model picker, <strong>Ctrl-R</strong> resume a past session, <strong>Ctrl-U</strong> clear the\ninput (<strong>Ctrl-Z</strong> restores it), <strong>Ctrl-C</strong> cancel or quit, <strong>Ctrl-D</strong> quit\nwith an empty input. Full list: <a href=\"https://github.com/Hmbown/CodeWhale/blob/main/docs/KEYBINDINGS.md\">KEYBINDINGS.md</a>.</li>\n<li>On exit it prints <code>To resume this session, run codewhale resume &lt;id&gt;</code>.</li>\n</ul>\n<p>Codewhale creates a <code>.codewhale/</code> directory in your repo. Add it to\n<code>.gitignore</code> (or your global gitignore).</p>\n<h3 id=\"headless-scripts-ci\">Headless (scripts, CI)</h3>\n"
"text": "<ul>\n<li>The composer is at the bottom. The footer shows the permission posture\n(<code>ask</code>), the mode (<code>work</code>) and the model (<code>DeepSeek · deepseek-flash</code>).</li>\n<li><strong>Shift+Tab</strong> cycles the permission posture: Ask → Auto-Review → Full Access.\n<strong>Tab</strong> (with an empty composer) cycles the mode: Plan → Work → Operate.</li>\n<li>In <strong>Ask</strong>, file edits in the workspace are applied and shown as a diff.\nShell commands stop at an <strong>APPROVAL</strong> prompt: <code>y</code> allow once, <code>a</code> allow for\nthis session, <code>n</code> deny, <code>Esc</code> abort the turn.</li>\n<li>Useful keys: <strong>F1</strong> help (or <code>/help</code>), <strong>Ctrl-K</strong> command palette, <strong>F3</strong>\nprovider/model picker, <strong>Ctrl-R</strong> resume a past session, <strong>Ctrl-U</strong> clear the\ninput (<strong>Ctrl-Z</strong> restores it), <strong>Ctrl-C</strong> cancel or quit, <strong>Ctrl-D</strong> quit\nwith an empty input. Full list: <a href=\"https://github.com/Hmbown/CodeWhale/blob/main/docs/KEYBINDINGS.md\">KEYBINDINGS.md</a>.</li>\n<li>On exit it prints <code>To resume this session, run codewhale resume &lt;id&gt;</code>.</li>\n</ul>\n<p>Codewhale creates a <code>.codewhale/</code> directory in your repo. Ignore its contents\nbut keep the committable <code>constitution.json</code> (these are the same patterns\n<code>/init</code> writes):</p>\n"
},
{
"kind": "code",
"text": "**/.codewhale/*\n!**/.codewhale/constitution.json"
},
{
"kind": "html",
"text": "<h3 id=\"headless-scripts-ci\">Headless (scripts, CI)</h3>\n"
},
{
"kind": "code",
@@ -354,11 +362,11 @@ export const INSTALL_GUIDE = {
},
{
"kind": "code",
"text": "rm -rf ~/.codewhale ~/.deepseek\n# per-repo dirs, e.g.:\nfind ~ -type d -name .codewhale -prune -print # review, then delete the ones you want"
"text": "rm -rf ~/.codewhale ~/.deepseek/snapshots\nrmdir ~/.deepseek 2>/dev/null # removes the parent only if it is now empty\n# per-repo dirs, e.g.:\nfind ~ -type d -name .codewhale -prune -print # review, then delete the ones you want"
},
{
"kind": "html",
"text": "<p>Codewhale wrote nothing outside <code>$HOME</code> and the repos it was used in: no\nsystem files, services or cron jobs. (I checked every file owned by the test\nusers outside their home directories.) If you already had a <code>~/.deepseek</code> from\nthe older DeepSeek-TUI, look before you delete it.</p>\n<hr>\n<h2 id=\"13-troubleshooting\">13. Troubleshooting</h2>\n<p>Every error below was hit while writing this guide.</p>\n<p><strong><code>bash: codewhale: command not found</code> right after installing.</strong>\n<code>~/.local/bin</code> isn&#39;t on PATH in this terminal. See\n<a href=\"#put-it-on-your-path\">Put it on your PATH</a>.</p>\n<p><strong><code>npm error code EACCES … permission denied, mkdir &#39;…/lib/node_modules/codewhale&#39;</code>.</strong>\nYour Node is system-owned. See <a href=\"#if-you-get-eacces-permission-denied\">§4</a>.\nDon&#39;t use sudo.</p>\n<p><strong><code>error: DeepSeek API key not found.</code> (from <code>codewhale exec</code>)</strong>\nNo key anywhere. Follow the printed steps, or see §8.</p>\n<p><strong>The TUI shows your message but never answers.</strong>\nNo key (v0.10.0 doesn&#39;t say so). Press F3 → DeepSeek → Enter → paste the key.</p>\n<p><strong><code>error: Responses API request failed … Authentication Fails, Your api key: ****dead is invalid</code>.</strong>\nThe key is wrong or revoked. Run <code>codewhale auth status --provider deepseek</code>\nto see <em>which</em> source is being used. Remember that config and the secret store\nbeat the env var. Fix with <code>codewhale auth set --provider deepseek</code>, or\n<code>codewhale auth clear --provider deepseek</code> to fall back to the env var. In the\nTUI, a bad key sends you to a &quot;Choose your model provider&quot; screen that marks\nDeepSeek <code>last check failed (authentication)</code>.</p>\n<p><strong><code>error: Network error: SSE stream request failed after HTTP/1.1 fallback: Responses API request failed. … on Windows or proxy networks, try CODEWHALE_FORCE_HTTP1=1 …</code>.</strong>\nDespite the wording, on Linux this usually just means <strong>no connection to\n<code>api.deepseek.com</code></strong>. Check with <code>curl -sI https://api.deepseek.com</code> (a <code>401</code>\nresponse is fine; it means the host is reachable). If you&#39;re behind a proxy,\nmake sure <code>HTTPS_PROXY</code> is exported. <code>codewhale doctor --probe-api</code> only says\n<code>✗ API connection failed</code> for both bad keys and network problems.</p>\n<p><strong><code>codewhale install: refusing to replace existing ~/.local/bin/codewhale</code>.</strong>\nA different version is already installed there. Run <code>codewhale update</code>, or\ndelete the two files first (§7 rollback), or install into a fresh\n<code>CODEWHALE_INSTALL_DIR</code>.</p>\n<p><strong><code>codewhale install: checksum mismatch for codew-linux-x64</code>.</strong>\nThe download was corrupted or tampered with. Nothing was installed. Retry, and\nif it repeats, don&#39;t use a mirror.</p>\n<p><strong><code>error: The package-managed executable was not changed.</code> (from <code>codewhale update</code>)</strong>\nYou installed with npm, Cargo or Homebrew. Update with that tool instead.</p>\n<p><strong><code>error: failed to run custom build command for libdbus-sys</code> (Cargo).</strong>\nRun <code>sudo apt-get install -y libdbus-1-dev pkg-config</code>.</p>\n<p><strong><code>error: No saved sessions found for workspace …</code> (from <code>exec --continue</code>).</strong>\nThe previous run was plain-text <code>exec</code>, which isn&#39;t saved. Use the TUI, or\n<code>--output-format stream-json</code>.</p>\n<p><strong>zsh completion suggests the wrong things after the first word.</strong>\nKnown v0.10.0 bug. bash and fish are fine.</p>\n<p><strong>Getting help:</strong> <code>codewhale doctor --json</code> produces a diagnostics bundle\nwithout secrets.</p>\n<hr>\n<h2 id=\"appendix-other-platforms-not-re-tested-in-this-revision\">Appendix: other platforms (not re-tested in this revision)</h2>\n<p>The sections below are carried over unchanged from the previous revision of\nthis page. They were <strong>not re-run</strong> for the v0.10.0 install test above\n(out of scope: Windows, macOS, Android/Termux, FreeBSD, mainland-China\nmirrors), apart from the macOS paths noted in <a href=\"#macos-notes\">macOS notes</a>.\nKnown contradictions with the published v0.10.0 assets, found by inspecting\nthem (<a href=\"https://github.com/Hmbown/Codewhale/blob/37ecdfcc49bc68a9b0d058b97c3946e62c34bd31/docs/install-report/v0.10.0-2026-09-23/DOC_DEFECTS.md\">details</a>, D15 and D16):</p>\n<ul>\n<li>The winget manifest in <code>packaging/winget/</code> is still at 0.9.6.</li>\n<li>v0.10.0 publishes both <code>codewhale-windows-x64.zip</code> (with an <code>install.bat</code>\nthat copies to <code>%USERPROFILE%\\bin</code>) and <code>codewhale-windows-x64-portable.zip</code>;\nthe sections below mention oLine truncated
"text": "<p>Codewhale wrote nothing outside <code>$HOME</code> and the repos it was used in: no\nsystem files, services or cron jobs. (I checked every file owned by the test\nusers outside their home directories.) The commands above delete only\n<code>~/.deepseek/snapshots</code>. If you still use the older DeepSeek-TUI, the rest of\n<code>~/.deepseek</code> (its config and sessions) is left alone.</p>\n<hr>\n<h2 id=\"13-troubleshooting\">13. Troubleshooting</h2>\n<p>Every error below was hit while writing this guide.</p>\n<p><strong><code>bash: codewhale: command not found</code> right after installing.</strong>\n<code>~/.local/bin</code> isn&#39;t on PATH in this terminal. See\n<a href=\"#put-it-on-your-path\">Put it on your PATH</a>.</p>\n<p><strong><code>npm error code EACCES … permission denied, mkdir &#39;…/lib/node_modules/codewhale&#39;</code>.</strong>\nYour Node is system-owned. See <a href=\"#if-you-get-eacces-permission-denied\">§4</a>.\nDon&#39;t use sudo.</p>\n<p><strong><code>error: DeepSeek API key not found.</code> (from <code>codewhale exec</code>)</strong>\nNo key anywhere. Follow the printed steps, or see §8.</p>\n<p><strong>The TUI shows your message but never answers.</strong>\nNo key (v0.10.0 doesn&#39;t say so). Press F3 → DeepSeek → Enter → paste the key.</p>\n<p><strong><code>error: Responses API request failed … Authentication Fails, Your api key: ****dead is invalid</code>.</strong>\nThe key is wrong or revoked. Run <code>codewhale auth status --provider deepseek</code>\nto see <em>which</em> source is being used. Remember that config and the secret store\nbeat the env var. Fix with <code>codewhale auth set --provider deepseek</code>, or\n<code>codewhale auth clear --provider deepseek</code> to fall back to the env var. In the\nTUI, a bad key sends you to a &quot;Choose your model provider&quot; screen that marks\nDeepSeek <code>last check failed (authentication)</code>.</p>\n<p><strong><code>error: Network error: SSE stream request failed after HTTP/1.1 fallback: Responses API request failed. … on Windows or proxy networks, try CODEWHALE_FORCE_HTTP1=1 …</code>.</strong>\nDespite the wording, on Linux this usually just means <strong>no connection to\n<code>api.deepseek.com</code></strong>. Check with <code>curl -sI https://api.deepseek.com</code> (a <code>401</code>\nresponse is fine; it means the host is reachable). If you&#39;re behind a proxy,\nmake sure <code>HTTPS_PROXY</code> is exported. <code>codewhale doctor --probe-api</code> only says\n<code>✗ API connection failed</code> for both bad keys and network problems.</p>\n<p><strong><code>codewhale install: refusing to replace existing ~/.local/bin/codewhale</code>.</strong>\nA different version is already installed there. Run <code>codewhale update</code>, or\ndelete the two files first (§7 rollback), or install into a fresh\n<code>CODEWHALE_INSTALL_DIR</code>.</p>\n<p><strong><code>codewhale install: checksum mismatch for codew-linux-x64</code>.</strong>\nThe download was corrupted or tampered with. Nothing was installed. Retry, and\nif it repeats, don&#39;t use a mirror.</p>\n<p><strong><code>error: The package-managed executable was not changed.</code> (from <code>codewhale update</code>)</strong>\nYou installed with npm, Cargo or Homebrew. Update with that tool instead.</p>\n<p><strong><code>error: failed to run custom build command for libdbus-sys</code> (Cargo).</strong>\nRun <code>sudo apt-get install -y libdbus-1-dev pkg-config</code>.</p>\n<p><strong><code>error: No saved sessions found for workspace …</code> (from <code>exec --continue</code>).</strong>\nThe previous run was plain-text <code>exec</code>, which isn&#39;t saved. Use the TUI, or\n<code>--output-format stream-json</code>.</p>\n<p><strong>zsh completion suggests the wrong things after the first word.</strong>\nKnown v0.10.0 bug. bash and fish are fine.</p>\n<p><strong>Getting help:</strong> <code>codewhale doctor --json</code> produces a diagnostics bundle\nwithout secrets.</p>\n<hr>\n<h2 id=\"appendix-other-platforms-not-re-tested-in-this-revision\">Appendix: other platforms (not re-tested in this revision)</h2>\n<p>The sections below are carried over unchanged from the previous revision of\nthis page. They were <strong>not re-run</strong> for the v0.10.0 install test above\n(out of scope: Windows, macOS, Android/Termux, FreeBSD, mainland-China\nmirrors), apart from the macOS paths noted in <a href=\"#macos-notes\">macOS notes</a>.\nKnown contradictions with the published v0.10.0 assets, found by inspecting\nthem (<a href=\"https://github.com/Hmbown/Codewhale/blob/37ecdfcc49bc68a9b0d058b97c3946e62c34bd31/docs/install-report/v0.10.0-2026-09-23/DOC_DEFECTS.md\">details</a>, D15 and D16):</p>\n<ul>\n<li>The winget manifest in <code>packaging/winget/</code> is still at 0.9.6.</li>\n<li>v0.10.0 publishes both <code>codewhale-windows-x64.zip</code> (with an <code>install.bat</code>\nthat copies to <code>%USERPROFILE%\\bin</codeLine truncated
},
{
"kind": "code",